@sleep2agi/commhub-server 0.9.0-preview.72 → 0.9.0-preview.73
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/bin/commhub.ts +82 -9
- package/package.json +1 -1
- package/src/agent-acl-http.test.ts +1 -1
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/db-adapter-translate.test.ts +5 -0
- package/src/db-adapter.ts +14 -0
- package/src/member-presence-http.test.ts +206 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/push.ts +80 -0
- package/src/server.ts +16 -2
- package/src/skillhub-http.test.ts +16 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/tools.ts +3 -2
package/bin/commhub.ts
CHANGED
|
@@ -12,19 +12,56 @@
|
|
|
12
12
|
|
|
13
13
|
const args = process.argv.slice(2);
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
15
|
+
// RFC-039 S5 — offline SQLite → PostgreSQL copy. Handled before any server
|
|
16
|
+
// option parsing: it never starts a Hub and never opens the default database.
|
|
17
|
+
if (args[0] === "migrate-to-pg") {
|
|
18
|
+
const flag = (name: string) => args.includes(name);
|
|
19
|
+
const value = (name: string) => { const i = args.indexOf(name); return i > 0 ? args[i + 1] : undefined; };
|
|
20
|
+
const MIGRATE_VALUE_FLAGS = new Set(["--from", "--to"]);
|
|
21
|
+
const MIGRATE_FLAGS = new Set(["--dry-run", "--i-know-this-is-a-copy", "--help", "-h"]);
|
|
22
|
+
for (let i = 1; i < args.length; i++) {
|
|
23
|
+
if (MIGRATE_VALUE_FLAGS.has(args[i])) { i++; continue; }
|
|
24
|
+
if (!MIGRATE_FLAGS.has(args[i])) {
|
|
25
|
+
console.error(`commhub-server migrate-to-pg: unknown argument ${args[i]}\nRun "commhub-server migrate-to-pg --help" for usage.`);
|
|
26
|
+
process.exit(2);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (flag("--help") || flag("-h") || !value("--from") || !value("--to")) {
|
|
23
30
|
console.log(`
|
|
31
|
+
Usage:
|
|
32
|
+
commhub-server migrate-to-pg --from <sqlite file> --to <postgres url> [--dry-run] [--i-know-this-is-a-copy]
|
|
33
|
+
|
|
34
|
+
Copies a stopped Hub's SQLite database into an EMPTY PostgreSQL database, in one
|
|
35
|
+
transaction, verifying per-table row counts and content hashes before COMMIT.
|
|
36
|
+
Experimental (RFC-039); available from commhub-server 0.9.0-preview.73.
|
|
37
|
+
Files under the uploads directory are not copied.
|
|
38
|
+
|
|
39
|
+
--dry-run do everything, then roll back and leave the target empty
|
|
40
|
+
--i-know-this-is-a-copy allow a source under ~/.commhub (refused by default)
|
|
41
|
+
`);
|
|
42
|
+
process.exit(flag("--help") || flag("-h") ? 0 : 2);
|
|
43
|
+
}
|
|
44
|
+
const { migrateSqliteToPg, MigrateRefused } = await import("../src/migrate-sqlite-to-pg.ts");
|
|
45
|
+
try {
|
|
46
|
+
await migrateSqliteToPg({
|
|
47
|
+
from: value("--from")!,
|
|
48
|
+
to: value("--to")!,
|
|
49
|
+
dryRun: flag("--dry-run"),
|
|
50
|
+
allowCommhubCopy: flag("--i-know-this-is-a-copy"),
|
|
51
|
+
});
|
|
52
|
+
process.exit(0);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.error(error instanceof MigrateRefused ? error.message : `[migrate-to-pg] FAILED, rolled back: ${(error as Error)?.message ?? error}`);
|
|
55
|
+
process.exit(error instanceof MigrateRefused ? 2 : 1);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const HELP = `
|
|
24
60
|
CommHub MCP Server — AI Agent 通信中枢
|
|
25
61
|
|
|
26
62
|
Usage:
|
|
27
63
|
commhub-server [options]
|
|
64
|
+
commhub-server migrate-to-pg --help Copy a stopped Hub's SQLite database into PostgreSQL (experimental)
|
|
28
65
|
|
|
29
66
|
Options:
|
|
30
67
|
--port, -p <port> Port to listen on (default: 9200, env: PORT)
|
|
@@ -34,6 +71,7 @@ Options:
|
|
|
34
71
|
--cors <origins> CORS origins, comma-separated (env: COMMHUB_CORS_ORIGINS)
|
|
35
72
|
--dev-open Explicit unauthenticated local development mode
|
|
36
73
|
--help, -h Show this help
|
|
74
|
+
--version, -v Print the version and exit
|
|
37
75
|
|
|
38
76
|
Environment Variables:
|
|
39
77
|
PORT Server port (default: 9200)
|
|
@@ -49,8 +87,43 @@ Examples:
|
|
|
49
87
|
commhub-server --port 9200 --token my-secret-token
|
|
50
88
|
commhub-server --port 9200 --dev-open
|
|
51
89
|
PORT=9200 COMMHUB_AUTH_TOKEN=secret commhub-server
|
|
52
|
-
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
// Anything this bin does not recognise is a usage error: exit 2 and never
|
|
93
|
+
// start a Hub. Before this, unknown words and flags were ignored and a Hub
|
|
94
|
+
// started on the defaults — `commhub-server <some-subcommand>` on a version
|
|
95
|
+
// without that subcommand opened ~/.commhub/commhub.db on :9200.
|
|
96
|
+
function usageError(message: string): never {
|
|
97
|
+
console.error(`commhub-server: ${message}\nRun "commhub-server --help" for usage.`);
|
|
98
|
+
process.exit(2);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const VALUE_FLAGS: Record<string, string> = {
|
|
102
|
+
"--port": "PORT", "-p": "PORT",
|
|
103
|
+
"--host": "HOST",
|
|
104
|
+
"--token": "COMMHUB_AUTH_TOKEN", "-t": "COMMHUB_AUTH_TOKEN",
|
|
105
|
+
"--db": "COMMHUB_DB",
|
|
106
|
+
"--cors": "COMMHUB_CORS_ORIGINS",
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
for (let i = 0; i < args.length; i++) {
|
|
110
|
+
const arg = args[i];
|
|
111
|
+
if (Object.hasOwn(VALUE_FLAGS, arg)) {
|
|
112
|
+
const value = args[i + 1];
|
|
113
|
+
if (value === undefined || value.startsWith("--")) usageError(`${arg} needs a value`);
|
|
114
|
+
process.env[VALUE_FLAGS[arg]] = value;
|
|
115
|
+
i++;
|
|
116
|
+
} else if (arg === "--dev-open") {
|
|
117
|
+
process.env.COMMHUB_DEV_OPEN = "1";
|
|
118
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
119
|
+
console.log(HELP);
|
|
120
|
+
process.exit(0);
|
|
121
|
+
} else if (arg === "--version" || arg === "-v") {
|
|
122
|
+
const pkg = (await import("../package.json", { with: { type: "json" } })).default as { version: string };
|
|
123
|
+
console.log(pkg.version);
|
|
53
124
|
process.exit(0);
|
|
125
|
+
} else {
|
|
126
|
+
usageError(arg.startsWith("-") ? `unknown option ${arg}` : `unknown command ${arg}`);
|
|
54
127
|
}
|
|
55
128
|
}
|
|
56
129
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.73",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -255,7 +255,7 @@ describe("humans: restricted members can still see and DM other humans", () => {
|
|
|
255
255
|
const r = await get(aliceToken, `/api/networks/${NET}/humans`);
|
|
256
256
|
expect(r.status).toBe(200);
|
|
257
257
|
expect(r.body.humans.map((h: any) => h.user_id).sort()).toEqual([adminId, aliceId, bobId].sort());
|
|
258
|
-
expect(Object.keys(r.body.humans[0]).sort()).toEqual(["display_name", "user_id", "username"]);
|
|
258
|
+
expect(Object.keys(r.body.humans[0]).sort()).toEqual(["display_name", "last_seen_at", "online", "user_id", "username"]);
|
|
259
259
|
// 非成员拿不到。
|
|
260
260
|
const outsider = register(`acl_out_${Date.now()}`, PW);
|
|
261
261
|
expect((await get(outsider.token!, `/api/networks/${NET}/humans`)).status).toBe(403);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
// commhub-server must refuse what it does not understand instead of starting a
|
|
8
|
+
// Hub on the defaults (the live ~/.commhub/commhub.db). Every child gets a
|
|
9
|
+
// throwaway HOME and a throwaway PORT, so even a regression binds nothing real.
|
|
10
|
+
const BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "commhub.ts");
|
|
11
|
+
const PKG_VERSION = JSON.parse(readFileSync(join(dirname(BIN), "..", "package.json"), "utf8")).version as string;
|
|
12
|
+
const home = mkdtempSync(join(tmpdir(), "anet-bin-args-"));
|
|
13
|
+
const PORT = String(26000 + Math.floor(Math.random() * 3000));
|
|
14
|
+
|
|
15
|
+
afterAll(() => rmSync(home, { recursive: true, force: true }));
|
|
16
|
+
|
|
17
|
+
function run(args: string[], extraEnv: Record<string, string> = {}, timeoutMs = 15_000) {
|
|
18
|
+
const proc = Bun.spawnSync(["bun", BIN, ...args], {
|
|
19
|
+
env: { PATH: process.env.PATH ?? "", HOME: home, PORT, HOST: "127.0.0.1", ...extraEnv },
|
|
20
|
+
stdout: "pipe", stderr: "pipe", timeout: timeoutMs,
|
|
21
|
+
});
|
|
22
|
+
return { code: proc.exitCode, stdout: proc.stdout.toString(), stderr: proc.stderr.toString() };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function listening(port: string): Promise<boolean> {
|
|
26
|
+
try { await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(500) }); return true; } catch { return false; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("commhub-server argument handling", () => {
|
|
30
|
+
for (const [args, message] of [
|
|
31
|
+
[["bogus"], "unknown command bogus"],
|
|
32
|
+
[["--port=29999"], "unknown option --port=29999"],
|
|
33
|
+
[["--bogus-flag"], "unknown option --bogus-flag"],
|
|
34
|
+
[["--port"], "--port needs a value"],
|
|
35
|
+
[["--db", "--dev-open"], "--db needs a value"],
|
|
36
|
+
] as const) {
|
|
37
|
+
test(`${args.join(" ")} → exit 2, no database, nothing listening`, async () => {
|
|
38
|
+
const r = run([...args]);
|
|
39
|
+
expect(r.code).toBe(2);
|
|
40
|
+
expect(r.stderr).toContain(message);
|
|
41
|
+
expect(r.stderr).toContain("commhub-server --help");
|
|
42
|
+
expect(existsSync(join(home, ".commhub"))).toBe(false);
|
|
43
|
+
expect(await listening(PORT)).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
test("migrate-to-pg with missing or unknown arguments exits 2 and starts nothing", async () => {
|
|
48
|
+
const missing = run(["migrate-to-pg", "--from", "/x.db"]);
|
|
49
|
+
expect(missing.code).toBe(2);
|
|
50
|
+
expect(missing.stdout).toContain("commhub-server migrate-to-pg --from <sqlite file> --to <postgres url>");
|
|
51
|
+
const unknown = run(["migrate-to-pg", "--from", "/x.db", "--to", "postgres://u@127.0.0.1/anet_x_test", "--bogus"]);
|
|
52
|
+
expect(unknown.code).toBe(2);
|
|
53
|
+
expect(unknown.stderr).toContain("unknown argument --bogus");
|
|
54
|
+
expect(existsSync(join(home, ".commhub"))).toBe(false);
|
|
55
|
+
expect(await listening(PORT)).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("--help and --version exit 0 without starting", async () => {
|
|
59
|
+
const help = run(["--help"]);
|
|
60
|
+
expect(help.code).toBe(0);
|
|
61
|
+
expect(help.stdout).toContain("--port, -p <port>");
|
|
62
|
+
const version = run(["--version"]);
|
|
63
|
+
expect(version.code).toBe(0);
|
|
64
|
+
expect(version.stdout.trim()).toBe(PKG_VERSION);
|
|
65
|
+
expect(existsSync(join(home, ".commhub"))).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("the known forms still start a Hub", async () => {
|
|
69
|
+
const db = join(home, "known.db");
|
|
70
|
+
const port = String(Number(PORT) + 1);
|
|
71
|
+
const child = Bun.spawn(["bun", BIN, "--port", port, "--host", "127.0.0.1", "--db", db, "--dev-open"], {
|
|
72
|
+
env: { PATH: process.env.PATH ?? "", HOME: home }, stdout: "pipe", stderr: "pipe",
|
|
73
|
+
});
|
|
74
|
+
try {
|
|
75
|
+
let up = false;
|
|
76
|
+
for (let i = 0; i < 100 && !up; i++) { up = await listening(port); if (!up) await Bun.sleep(100); }
|
|
77
|
+
expect(up).toBe(true);
|
|
78
|
+
expect(existsSync(db)).toBe(true);
|
|
79
|
+
expect(existsSync(join(home, ".commhub", "commhub.db"))).toBe(false);
|
|
80
|
+
} finally {
|
|
81
|
+
child.kill();
|
|
82
|
+
await child.exited;
|
|
83
|
+
}
|
|
84
|
+
}, 30_000);
|
|
85
|
+
});
|
|
@@ -38,6 +38,11 @@ describe("RFC-039 S2a sqliteToPostgres", () => {
|
|
|
38
38
|
expect(sqliteToPostgres("CAST(x AS INTEGER)")).toBe("CAST(x AS BIGINT)");
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
test("REAL → DOUBLE PRECISION (SQLite REAL is 8-byte), identifiers containing real untouched", () => {
|
|
42
|
+
expect(sqliteToPostgres("mem_used_gb REAL, realm TEXT")).toBe("mem_used_gb DOUBLE PRECISION, realm TEXT");
|
|
43
|
+
expect(sqliteToPostgres("CAST(x AS REAL)")).toBe("CAST(x AS DOUBLE PRECISION)");
|
|
44
|
+
});
|
|
45
|
+
|
|
41
46
|
test("BLOB → BYTEA, identifiers containing blob untouched", () => {
|
|
42
47
|
expect(sqliteToPostgres("iv BLOB NOT NULL, blob_ref TEXT")).toBe("iv BYTEA NOT NULL, blob_ref TEXT");
|
|
43
48
|
});
|
package/src/db-adapter.ts
CHANGED
|
@@ -90,6 +90,17 @@ export class SQLiteAdapter implements DbAdapter {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
close(): void {
|
|
93
|
+
// #2151: once more than 20 distinct SQL strings have gone through query(),
|
|
94
|
+
// bun:sqlite's close() can no longer really close the connection (measured:
|
|
95
|
+
// close(true) reports "database is locked"), so SQLite never checkpoints and
|
|
96
|
+
// a cleanly stopped Hub left its latest writes in -wal. Checkpoint first,
|
|
97
|
+
// so the .db file alone is complete after a clean stop.
|
|
98
|
+
try {
|
|
99
|
+
const r = this.rawDb.query<{ busy: number; log: number; checkpointed: number }, []>("PRAGMA wal_checkpoint(TRUNCATE)").get();
|
|
100
|
+
if (r && r.busy !== 0) console.warn(`[commhub] WAL checkpoint on close was busy: ${JSON.stringify(r)}`);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
console.warn(`[commhub] WAL checkpoint on close failed: ${(e as Error)?.message ?? e}`);
|
|
103
|
+
}
|
|
93
104
|
this.rawDb.close();
|
|
94
105
|
}
|
|
95
106
|
}
|
|
@@ -140,6 +151,9 @@ export function sqliteToPostgres(sql: string): string {
|
|
|
140
151
|
// INTEGER → BIGINT: SQLite integers are 64-bit, and the Hub stores epoch
|
|
141
152
|
// milliseconds in INTEGER columns (a 32-bit PG integer overflows).
|
|
142
153
|
s = s.replace(/\bINTEGER\b/gi, "BIGINT");
|
|
154
|
+
// REAL → DOUBLE PRECISION: SQLite REAL is 8-byte; PostgreSQL REAL is 4-byte
|
|
155
|
+
// and would silently round telemetry and scores.
|
|
156
|
+
s = s.replace(/\bREAL\b/gi, "DOUBLE PRECISION");
|
|
143
157
|
// BLOB → BYTEA
|
|
144
158
|
s = s.replace(/\bBLOB\b/gi, "BYTEA");
|
|
145
159
|
return s;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// 人员列表的在线状态 —— HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
|
|
2
|
+
//
|
|
3
|
+
// GET /api/networks/:id/humans 给每个人加 online / last_seen_at(判据 = 此刻有没有活着的用户流
|
|
4
|
+
// /events/users/me);用户流上推 member_presence。两个方向都测:
|
|
5
|
+
// 看得到:普通成员、受限成员、viewer;连上 → online=true,断开 → online=false + last_seen_at。
|
|
6
|
+
// 看不到:非成员 403;节点令牌拿到的仍是原来三个身份字段;别的网络的人收不到推送;自己收不到自己的。
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
9
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
import { tmpdir } from "os";
|
|
12
|
+
import { register } from "./auth.js";
|
|
13
|
+
import { db, hashToken, generateNetworkToken, generateId } from "./db.js";
|
|
14
|
+
|
|
15
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-member-presence-"));
|
|
16
|
+
let BASE = "";
|
|
17
|
+
let hub: any = null;
|
|
18
|
+
|
|
19
|
+
const PW = "PresenceTestPassw0rd!x";
|
|
20
|
+
let ownerToken = "";
|
|
21
|
+
let NET = "";
|
|
22
|
+
const users: Record<string, { token: string; userId: string }> = {};
|
|
23
|
+
let nodeToken = "";
|
|
24
|
+
|
|
25
|
+
const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
|
|
26
|
+
|
|
27
|
+
async function get(token: string, path: string): Promise<{ status: number; body: any }> {
|
|
28
|
+
const res = await fetch(`${BASE}${path}`, { headers: auth(token) });
|
|
29
|
+
const text = await res.text();
|
|
30
|
+
let body: any = text;
|
|
31
|
+
try { body = JSON.parse(text); } catch {}
|
|
32
|
+
return { status: res.status, body };
|
|
33
|
+
}
|
|
34
|
+
async function post(token: string, path: string, payload: unknown): Promise<{ status: number; body: any }> {
|
|
35
|
+
const res = await fetch(`${BASE}${path}`, { method: "POST", headers: { ...auth(token), "Content-Type": "application/json" }, body: JSON.stringify(payload) });
|
|
36
|
+
const text = await res.text();
|
|
37
|
+
let body: any = text;
|
|
38
|
+
try { body = JSON.parse(text); } catch {}
|
|
39
|
+
return { status: res.status, body };
|
|
40
|
+
}
|
|
41
|
+
async function login(username: string): Promise<{ token: string; userId: string }> {
|
|
42
|
+
const r = await post("", "/api/auth/login", { username, password: PW });
|
|
43
|
+
expect(r.status).toBe(200);
|
|
44
|
+
return { token: r.body.token, userId: r.body.user.user_id };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 一条用户流:收集 data: 帧,close() 模拟 app 退出。 */
|
|
48
|
+
async function openUserStream(token: string, networkId: string) {
|
|
49
|
+
const ctrl = new AbortController();
|
|
50
|
+
const res = await fetch(`${BASE}/events/users/me?network_id=${encodeURIComponent(networkId)}`, { headers: auth(token), signal: ctrl.signal });
|
|
51
|
+
expect(res.status).toBe(200);
|
|
52
|
+
const events: any[] = [];
|
|
53
|
+
const reader = res.body!.getReader();
|
|
54
|
+
const decoder = new TextDecoder();
|
|
55
|
+
let buf = "";
|
|
56
|
+
void (async () => {
|
|
57
|
+
try {
|
|
58
|
+
for (;;) {
|
|
59
|
+
const { done, value } = await reader.read();
|
|
60
|
+
if (done) break;
|
|
61
|
+
buf += decoder.decode(value);
|
|
62
|
+
let i: number;
|
|
63
|
+
while ((i = buf.indexOf("\n\n")) >= 0) {
|
|
64
|
+
const frame = buf.slice(0, i);
|
|
65
|
+
buf = buf.slice(i + 2);
|
|
66
|
+
for (const line of frame.split("\n")) if (line.startsWith("data: ")) events.push(JSON.parse(line.slice(6)));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch { /* aborted */ }
|
|
70
|
+
})();
|
|
71
|
+
await waitFor(() => events.some((e) => e.type === "connected"));
|
|
72
|
+
return { events, close: () => ctrl.abort(), presence: () => events.filter((e) => e.type === "member_presence") };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function waitFor(cond: () => boolean | Promise<boolean>, ms = 3000): Promise<void> {
|
|
76
|
+
const until = Date.now() + ms;
|
|
77
|
+
while (Date.now() < until) {
|
|
78
|
+
if (await cond()) return;
|
|
79
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
80
|
+
}
|
|
81
|
+
throw new Error("waitFor timed out");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const humanOf = async (token: string, userId: string) =>
|
|
85
|
+
(await get(token, `/api/networks/${NET}/humans`)).body.humans.find((h: any) => h.user_id === userId);
|
|
86
|
+
|
|
87
|
+
beforeAll(async () => {
|
|
88
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
89
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
90
|
+
process.env.HOST = "127.0.0.1";
|
|
91
|
+
|
|
92
|
+
const owner = register(`pres_owner_${Date.now()}`, PW, undefined, "Owner");
|
|
93
|
+
expect(owner.ok).toBe(true);
|
|
94
|
+
ownerToken = owner.token!;
|
|
95
|
+
NET = owner.network_id!;
|
|
96
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [owner.user!.user_id]);
|
|
97
|
+
users.owner = { token: ownerToken, userId: owner.user!.user_id };
|
|
98
|
+
|
|
99
|
+
const mod: any = await import("./server.js");
|
|
100
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
101
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
102
|
+
|
|
103
|
+
// 受限成员(默认 agent_access=granted)、viewer、普通成员;外人自己一个网络。
|
|
104
|
+
for (const [name, role] of [["pres_alpha", "member"], ["pres_beta", "member"], ["pres_gamma", "viewer"]] as const) {
|
|
105
|
+
const r = await post(ownerToken, "/api/admin/users", { username: name, password: PW, network_id: NET, role });
|
|
106
|
+
expect(r.status).toBe(200);
|
|
107
|
+
users[name] = await login(name);
|
|
108
|
+
}
|
|
109
|
+
const outsider = register(`pres_out_${Date.now()}`, PW);
|
|
110
|
+
expect(outsider.ok).toBe(true);
|
|
111
|
+
users.outsider = { token: outsider.token!, userId: outsider.user!.user_id };
|
|
112
|
+
users.outsider_net = { token: outsider.token!, userId: outsider.network_id! };
|
|
113
|
+
|
|
114
|
+
nodeToken = generateNetworkToken();
|
|
115
|
+
db.run("INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, 'pres-node', 'network')",
|
|
116
|
+
[generateId("tok"), hashToken(nodeToken), users.owner.userId, NET]);
|
|
117
|
+
}, 30_000);
|
|
118
|
+
|
|
119
|
+
afterAll(() => {
|
|
120
|
+
try { hub?.stop?.(true); } catch {}
|
|
121
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("GET /api/networks/:id/humans — online / last_seen_at", () => {
|
|
125
|
+
test("never connected since hub start → online=false, last_seen_at=null (unknown, not 'long ago')", async () => {
|
|
126
|
+
const r = await get(users.pres_alpha.token, `/api/networks/${NET}/humans`);
|
|
127
|
+
expect(r.status).toBe(200);
|
|
128
|
+
expect(Object.keys(r.body.humans[0]).sort()).toEqual(["display_name", "last_seen_at", "online", "user_id", "username"]);
|
|
129
|
+
const beta = r.body.humans.find((h: any) => h.user_id === users.pres_beta.userId);
|
|
130
|
+
expect(beta.online).toBe(false);
|
|
131
|
+
expect(beta.last_seen_at).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("user stream open → online for member, restricted member and viewer; closed → offline with last_seen_at", async () => {
|
|
135
|
+
const watcher = await openUserStream(users.pres_alpha.token, NET);
|
|
136
|
+
const before = Date.now();
|
|
137
|
+
const beta = await openUserStream(users.pres_beta.token, NET);
|
|
138
|
+
|
|
139
|
+
for (const viewer of ["pres_alpha", "pres_gamma", "owner"]) {
|
|
140
|
+
const h = await humanOf(users[viewer].token, users.pres_beta.userId);
|
|
141
|
+
expect(h.online).toBe(true);
|
|
142
|
+
expect(Date.parse(h.last_seen_at)).toBeGreaterThanOrEqual(before - 1000);
|
|
143
|
+
}
|
|
144
|
+
// 推送:alpha 收到 beta 上线;beta 收不到自己的。
|
|
145
|
+
await waitFor(() => watcher.presence().some((e) => e.member_user_id === users.pres_beta.userId && e.online === true));
|
|
146
|
+
const up = watcher.presence().find((e) => e.member_user_id === users.pres_beta.userId)!;
|
|
147
|
+
expect(up.network_id).toBe(NET);
|
|
148
|
+
expect(up.user_id).toBe(users.pres_alpha.userId); // 收件人
|
|
149
|
+
expect(beta.presence().filter((e) => e.member_user_id === users.pres_beta.userId)).toEqual([]);
|
|
150
|
+
|
|
151
|
+
beta.close();
|
|
152
|
+
await waitFor(async () => (await humanOf(users.pres_alpha.token, users.pres_beta.userId)).online === false);
|
|
153
|
+
const off = await humanOf(users.pres_gamma.token, users.pres_beta.userId);
|
|
154
|
+
expect(off.online).toBe(false);
|
|
155
|
+
expect(Date.parse(off.last_seen_at)).toBeGreaterThanOrEqual(before - 1000);
|
|
156
|
+
await waitFor(() => watcher.presence().some((e) => e.member_user_id === users.pres_beta.userId && e.online === false));
|
|
157
|
+
const down = watcher.presence().find((e) => e.member_user_id === users.pres_beta.userId && e.online === false)!;
|
|
158
|
+
expect(typeof down.last_seen_at).toBe("string");
|
|
159
|
+
watcher.close();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("two streams for one user: closing one keeps them online, no offline event", async () => {
|
|
163
|
+
const watcher = await openUserStream(users.pres_alpha.token, NET);
|
|
164
|
+
const a = await openUserStream(users.pres_beta.token, NET);
|
|
165
|
+
const b = await openUserStream(users.pres_beta.token, NET);
|
|
166
|
+
await waitFor(() => watcher.presence().some((e) => e.member_user_id === users.pres_beta.userId && e.online));
|
|
167
|
+
a.close();
|
|
168
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
169
|
+
expect((await humanOf(users.pres_alpha.token, users.pres_beta.userId)).online).toBe(true);
|
|
170
|
+
expect(watcher.presence().filter((e) => e.member_user_id === users.pres_beta.userId && !e.online)).toEqual([]);
|
|
171
|
+
b.close();
|
|
172
|
+
await waitFor(async () => (await humanOf(users.pres_alpha.token, users.pres_beta.userId)).online === false);
|
|
173
|
+
watcher.close();
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("who must NOT see presence", () => {
|
|
178
|
+
test("non-member → 403, no humans at all", async () => {
|
|
179
|
+
const r = await get(users.outsider.token, `/api/networks/${NET}/humans`);
|
|
180
|
+
expect(r.status).toBe(403);
|
|
181
|
+
expect(r.body.humans).toBeUndefined();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("node token → same three identity fields as before, no online / last_seen_at", async () => {
|
|
185
|
+
const beta = await openUserStream(users.pres_beta.token, NET);
|
|
186
|
+
const r = await get(nodeToken, `/api/networks/${NET}/humans`);
|
|
187
|
+
expect(r.status).toBe(200);
|
|
188
|
+
for (const h of r.body.humans) expect(Object.keys(h).sort()).toEqual(["display_name", "user_id", "username"]);
|
|
189
|
+
// 节点令牌也不能订阅用户流(既有行为,这里钉住:不能借它收 member_presence)。
|
|
190
|
+
expect((await fetch(`${BASE}/events/users/me?network_id=${NET}`, { headers: auth(nodeToken) })).status).toBe(403);
|
|
191
|
+
beta.close();
|
|
192
|
+
await waitFor(async () => (await humanOf(users.pres_alpha.token, users.pres_beta.userId)).online === false);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("a user connected in another network gets no member_presence about this network's people", async () => {
|
|
196
|
+
const outsiderStream = await openUserStream(users.outsider.token, users.outsider_net.userId);
|
|
197
|
+
const watcher = await openUserStream(users.pres_alpha.token, NET);
|
|
198
|
+
const beta = await openUserStream(users.pres_beta.token, NET);
|
|
199
|
+
await waitFor(() => watcher.presence().some((e) => e.member_user_id === users.pres_beta.userId && e.online));
|
|
200
|
+
beta.close();
|
|
201
|
+
await waitFor(() => watcher.presence().some((e) => e.member_user_id === users.pres_beta.userId && !e.online));
|
|
202
|
+
expect(outsiderStream.presence()).toEqual([]);
|
|
203
|
+
watcher.close();
|
|
204
|
+
outsiderStream.close();
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { MigrateRefused, migrateSqliteToPg } from "./migrate-sqlite-to-pg";
|
|
6
|
+
|
|
7
|
+
// Only the refusals that happen before any connection is attempted; the full
|
|
8
|
+
// copy runs against a real PostgreSQL in test2123 (migrate-stage.sh).
|
|
9
|
+
const home = mkdtempSync(join(tmpdir(), "anet-migrate-home-"));
|
|
10
|
+
const commhub = join(home, ".commhub");
|
|
11
|
+
mkdirSync(commhub);
|
|
12
|
+
writeFileSync(join(commhub, "commhub.db"), "");
|
|
13
|
+
const elsewhere = join(home, "copies");
|
|
14
|
+
mkdirSync(elsewhere);
|
|
15
|
+
symlinkSync(join(commhub, "commhub.db"), join(elsewhere, "link.db"));
|
|
16
|
+
const PG = "postgres://user:s3cret@127.0.0.1:1/anet_never_test";
|
|
17
|
+
|
|
18
|
+
afterAll(() => rmSync(home, { recursive: true, force: true }));
|
|
19
|
+
|
|
20
|
+
async function refusal(opts: Parameters<typeof migrateSqliteToPg>[0]): Promise<string> {
|
|
21
|
+
try {
|
|
22
|
+
await migrateSqliteToPg({ ...opts, home, log: () => {} });
|
|
23
|
+
} catch (e) {
|
|
24
|
+
expect(e).toBeInstanceOf(MigrateRefused);
|
|
25
|
+
return String((e as Error).message);
|
|
26
|
+
}
|
|
27
|
+
throw new Error("expected a refusal");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("RFC-039 S5 migrate-to-pg refusals", () => {
|
|
31
|
+
test("target must be a postgres URL", async () => {
|
|
32
|
+
expect(await refusal({ from: join(elsewhere, "x.db"), to: "mysql://h/db" })).toContain("--to must be a postgres");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("missing source", async () => {
|
|
36
|
+
expect(await refusal({ from: join(elsewhere, "missing.db"), to: PG })).toContain("does not exist");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("the live Hub directory ~/.commhub is refused, also through a symlink", async () => {
|
|
40
|
+
for (const from of [join(commhub, "commhub.db"), join(elsewhere, "link.db")]) {
|
|
41
|
+
const message = await refusal({ from, to: PG });
|
|
42
|
+
expect(message).toContain("live Hub's directory");
|
|
43
|
+
expect(message).toContain("--i-know-this-is-a-copy");
|
|
44
|
+
expect(message).not.toContain("s3cret");
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC-039 S5 — offline copy of a Hub's SQLite database into an EMPTY
|
|
3
|
+
* PostgreSQL database.
|
|
4
|
+
*
|
|
5
|
+
* commhub-server migrate-to-pg --from <sqlite file> --to <postgres url>
|
|
6
|
+
* [--dry-run] [--i-know-this-is-a-copy]
|
|
7
|
+
*
|
|
8
|
+
* Contract:
|
|
9
|
+
* - offline: no running process may have the source (or its -wal/-shm)
|
|
10
|
+
* open (checked through /proc on Linux). A stopped Hub normally leaves a
|
|
11
|
+
* -wal behind — that is expected, and its contents are included: the
|
|
12
|
+
* source is opened read-only and read through a `VACUUM INTO` snapshot in
|
|
13
|
+
* a private temp dir.
|
|
14
|
+
* - never the live Hub's database: a source whose real path is under
|
|
15
|
+
* ~/.commhub is refused unless --i-know-this-is-a-copy is given.
|
|
16
|
+
* - empty target only: the target's `public` schema must hold no tables,
|
|
17
|
+
* views, sequences or functions. The schema is then built by the Hub's
|
|
18
|
+
* own module graph (no second copy of the DDL).
|
|
19
|
+
* - one transaction: every table is copied and verified — row counts plus
|
|
20
|
+
* a SHA-256 over canonicalised rows — before COMMIT; any mismatch or
|
|
21
|
+
* error rolls everything back.
|
|
22
|
+
* - --dry-run does all of the above, rolls back, and drops what it created,
|
|
23
|
+
* leaving the target as it found it.
|
|
24
|
+
* - the target URL is printed with credentials removed.
|
|
25
|
+
*/
|
|
26
|
+
import { Database } from "bun:sqlite";
|
|
27
|
+
import { createHash } from "node:crypto";
|
|
28
|
+
import { existsSync, mkdtempSync, readdirSync, readlinkSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
29
|
+
import { tmpdir } from "node:os";
|
|
30
|
+
import { join, resolve, sep } from "node:path";
|
|
31
|
+
import { PgAdapter, redactPgUrl, type DbAdapter } from "./db-adapter.js";
|
|
32
|
+
|
|
33
|
+
export type MigrateOptions = {
|
|
34
|
+
from: string;
|
|
35
|
+
to: string;
|
|
36
|
+
dryRun?: boolean;
|
|
37
|
+
allowCommhubCopy?: boolean;
|
|
38
|
+
home?: string;
|
|
39
|
+
log?: (line: string) => void;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type TableReport = { table: string; rows: number; sha256: string };
|
|
43
|
+
export type MigrateReport = { ok: true; dryRun: boolean; tables: TableReport[] };
|
|
44
|
+
|
|
45
|
+
class DryRunRollback extends Error {}
|
|
46
|
+
|
|
47
|
+
/** Refusals are thrown as MigrateRefused so the CLI can print them without a stack. */
|
|
48
|
+
export class MigrateRefused extends Error {}
|
|
49
|
+
|
|
50
|
+
function refuse(message: string): never {
|
|
51
|
+
throw new MigrateRefused(`[migrate-to-pg] REFUSING: ${message}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const quoteIdent = (name: string) => `"${name.replace(/"/g, '""')}"`;
|
|
55
|
+
|
|
56
|
+
/** One canonical text per value, identical for bun:sqlite and PgAdapter output. */
|
|
57
|
+
function canonical(value: unknown): unknown {
|
|
58
|
+
if (value === null || value === undefined) return null;
|
|
59
|
+
if (typeof value === "bigint") return `i:${value.toString()}`;
|
|
60
|
+
if (typeof value === "number") return Number.isInteger(value) ? `i:${value}` : `f:${value}`;
|
|
61
|
+
if (value instanceof Uint8Array) return `b:${Buffer.from(value).toString("hex")}`;
|
|
62
|
+
if (typeof value === "string" && /^-?\d+$/.test(value) && value.length > 15) return `i:${value}`; // unsafe int8 comes back as text
|
|
63
|
+
return `s:${String(value)}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Order-independent digest: hash each row, sort the row hashes, hash the list. */
|
|
67
|
+
function tableDigest(rows: Iterable<Record<string, unknown>>, cols: string[]): { rows: number; sha256: string } {
|
|
68
|
+
const rowHashes: string[] = [];
|
|
69
|
+
for (const row of rows) {
|
|
70
|
+
rowHashes.push(createHash("sha256").update(JSON.stringify(cols.map((c) => canonical(row[c])))).digest("hex"));
|
|
71
|
+
}
|
|
72
|
+
rowHashes.sort();
|
|
73
|
+
return { rows: rowHashes.length, sha256: createHash("sha256").update(rowHashes.join("\n")).digest("hex") };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function bindValue(value: unknown): unknown {
|
|
77
|
+
if (typeof value === "bigint") {
|
|
78
|
+
const n = Number(value);
|
|
79
|
+
return Number.isSafeInteger(n) ? n : value.toString();
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function checkSource(from: string, home: string, allowCommhubCopy: boolean): string {
|
|
85
|
+
const abs = resolve(from);
|
|
86
|
+
if (!existsSync(abs)) refuse(`source ${abs} does not exist`);
|
|
87
|
+
const real = realpathSync(abs);
|
|
88
|
+
if (!statSync(real).isFile()) refuse(`source ${real} is not a file`);
|
|
89
|
+
const commhubDir = join(home, ".commhub");
|
|
90
|
+
const guarded = existsSync(commhubDir) ? realpathSync(commhubDir) : resolve(commhubDir);
|
|
91
|
+
if ((real === guarded || real.startsWith(guarded + sep)) && !allowCommhubCopy) {
|
|
92
|
+
refuse(`source ${real} is under ${guarded}, the live Hub's directory. Stop the Hub, copy the database elsewhere, ` +
|
|
93
|
+
"and migrate the copy — or pass --i-know-this-is-a-copy if this file already is one");
|
|
94
|
+
}
|
|
95
|
+
const holders = processesHolding([real, `${real}-wal`, `${real}-shm`]);
|
|
96
|
+
if (holders === null) {
|
|
97
|
+
console.warn("[migrate-to-pg] cannot inspect open files on this platform — make sure the Hub using this database is stopped");
|
|
98
|
+
} else if (holders.length) {
|
|
99
|
+
refuse(`${real} is open in running process(es) ${holders.join(", ")} — stop the Hub first`);
|
|
100
|
+
}
|
|
101
|
+
return real;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** PIDs (other than ours) with any of `paths` open; null where /proc is unavailable. */
|
|
105
|
+
function processesHolding(paths: string[]): number[] | null {
|
|
106
|
+
if (!existsSync("/proc/self/fd")) return null;
|
|
107
|
+
const wanted = new Set(paths);
|
|
108
|
+
const holders: number[] = [];
|
|
109
|
+
for (const entry of readdirSync("/proc")) {
|
|
110
|
+
const pid = Number(entry);
|
|
111
|
+
if (!Number.isInteger(pid) || pid === process.pid) continue;
|
|
112
|
+
let fds: string[];
|
|
113
|
+
try { fds = readdirSync(`/proc/${pid}/fd`); } catch { continue; } // gone, or another user's
|
|
114
|
+
for (const fd of fds) {
|
|
115
|
+
let target = "";
|
|
116
|
+
try { target = readlinkSync(`/proc/${pid}/fd/${fd}`); } catch { continue; }
|
|
117
|
+
if (wanted.has(target.replace(/ \(deleted\)$/, ""))) { holders.push(pid); break; }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return holders;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function publicObjectCount(pg: PgAdapter): number {
|
|
124
|
+
const row = pg.get<{ n: number }>(
|
|
125
|
+
`SELECT (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public')
|
|
126
|
+
+ (SELECT COUNT(*) FROM information_schema.sequences WHERE sequence_schema = 'public')
|
|
127
|
+
+ (SELECT COUNT(*) FROM information_schema.routines WHERE routine_schema = 'public') AS n`,
|
|
128
|
+
);
|
|
129
|
+
return Number(row?.n ?? 0);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function dropEverythingInPublic(pg: DbAdapter) {
|
|
133
|
+
// Only ever called on a target that was verified empty before we started,
|
|
134
|
+
// so everything here was created by this run.
|
|
135
|
+
pg.exec(`DO $$ DECLARE r record; BEGIN
|
|
136
|
+
FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP
|
|
137
|
+
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE';
|
|
138
|
+
END LOOP;
|
|
139
|
+
FOR r IN SELECT p.oid::regprocedure AS sig FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'public' LOOP
|
|
140
|
+
EXECUTE 'DROP FUNCTION IF EXISTS ' || r.sig || ' CASCADE';
|
|
141
|
+
END LOOP;
|
|
142
|
+
FOR r IN SELECT sequencename FROM pg_sequences WHERE schemaname = 'public' LOOP
|
|
143
|
+
EXECUTE 'DROP SEQUENCE IF EXISTS public.' || quote_ident(r.sequencename) || ' CASCADE';
|
|
144
|
+
END LOOP;
|
|
145
|
+
END $$`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function migrateSqliteToPg(opts: MigrateOptions): Promise<MigrateReport> {
|
|
149
|
+
const log = opts.log ?? ((line: string) => console.log(line));
|
|
150
|
+
const home = opts.home ?? process.env.HOME ?? "";
|
|
151
|
+
if (!/^postgres(ql)?:\/\//.test(opts.to)) refuse("--to must be a postgres:// or postgresql:// URL");
|
|
152
|
+
const source = checkSource(opts.from, home, !!opts.allowCommhubCopy);
|
|
153
|
+
log(`[migrate-to-pg] source ${source}`);
|
|
154
|
+
log(`[migrate-to-pg] target ${redactPgUrl(opts.to)}${opts.dryRun ? " (dry run)" : ""}`);
|
|
155
|
+
|
|
156
|
+
// Empty-target check on a connection of our own, before the schema exists.
|
|
157
|
+
const probe = new PgAdapter(opts.to);
|
|
158
|
+
const existing = publicObjectCount(probe);
|
|
159
|
+
probe.close();
|
|
160
|
+
if (existing > 0) refuse(`target schema "public" is not empty (${existing} tables/sequences/functions). Migrate only into a new, empty database`);
|
|
161
|
+
|
|
162
|
+
// Snapshot the source: a consistent read that cannot touch the original.
|
|
163
|
+
const snapDir = mkdtempSync(join(tmpdir(), "anet-migrate-"));
|
|
164
|
+
const snapshot = join(snapDir, "snapshot.db");
|
|
165
|
+
try {
|
|
166
|
+
const original = new Database(source, { readonly: true });
|
|
167
|
+
original.exec(`VACUUM INTO '${snapshot.replace(/'/g, "''")}'`);
|
|
168
|
+
original.close();
|
|
169
|
+
const src = new Database(snapshot, { readonly: true, safeIntegers: true });
|
|
170
|
+
|
|
171
|
+
// Build the target schema exactly as the Hub does: import its module graph
|
|
172
|
+
// with DATABASE_URL pointing at the target.
|
|
173
|
+
delete process.env.COMMHUB_DB;
|
|
174
|
+
process.env.DATABASE_URL = opts.to;
|
|
175
|
+
await import("./server.js");
|
|
176
|
+
const { db } = await import("./db.js");
|
|
177
|
+
if (db.dialect !== "postgres") throw new Error("schema build did not select PostgreSQL");
|
|
178
|
+
|
|
179
|
+
const tables = (src.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all() as { name: string }[])
|
|
180
|
+
.map((r) => r.name);
|
|
181
|
+
const report: TableReport[] = [];
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
db.transaction(() => {
|
|
185
|
+
for (const table of tables) {
|
|
186
|
+
const srcCols = (src.query(`PRAGMA table_info(${quoteIdent(table)})`).all() as { name: string }[]).map((c) => c.name);
|
|
187
|
+
const tgtCols = new Set(db.all<{ column_name: string }>(
|
|
188
|
+
"SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ?1", table,
|
|
189
|
+
).map((c) => c.column_name));
|
|
190
|
+
const srcCount = Number((src.query(`SELECT COUNT(*) AS n FROM ${quoteIdent(table)}`).get() as { n: bigint | number }).n);
|
|
191
|
+
if (tgtCols.size === 0) {
|
|
192
|
+
if (srcCount > 0) throw new Error(`table ${table} (${srcCount} rows) does not exist in the Hub's PostgreSQL schema`);
|
|
193
|
+
log(`[migrate-to-pg] skip ${table}: not in the PostgreSQL schema, and empty in the source`);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const missing = srcCols.filter((c) => !tgtCols.has(c));
|
|
197
|
+
if (missing.length && srcCount > 0) throw new Error(`table ${table}: column(s) ${missing.join(", ")} do not exist in PostgreSQL`);
|
|
198
|
+
const cols = srcCols.filter((c) => tgtCols.has(c));
|
|
199
|
+
// SQLite's implicit rowid carries insertion order; PostgreSQL keeps it
|
|
200
|
+
// in a real `rowid` column on the tables that sort by it (RFC-039 F3).
|
|
201
|
+
const copyRowid = tgtCols.has("rowid") && !srcCols.includes("rowid");
|
|
202
|
+
const selectCols = [...(copyRowid ? ["rowid AS rowid"] : []), ...cols.map(quoteIdent)].join(", ");
|
|
203
|
+
const insertCols = [...(copyRowid ? ["rowid"] : []), ...cols];
|
|
204
|
+
|
|
205
|
+
// Triggers are off only while this table is being filled. Re-enabled
|
|
206
|
+
// in `finally` so no path — including a failed batch — leaves them
|
|
207
|
+
// off; a rollback also restores them (DDL is transactional in PG).
|
|
208
|
+
db.exec(`ALTER TABLE ${quoteIdent(table)} DISABLE TRIGGER USER`);
|
|
209
|
+
try {
|
|
210
|
+
db.run(`DELETE FROM ${quoteIdent(table)}`); // rows the schema bootstrap seeded; the source is authoritative
|
|
211
|
+
const perBatch = Math.max(1, Math.floor(30000 / insertCols.length));
|
|
212
|
+
const stmt = src.query(`SELECT ${selectCols} FROM ${quoteIdent(table)} ORDER BY rowid LIMIT ?1 OFFSET ?2`);
|
|
213
|
+
for (let offset = 0; offset < srcCount; offset += perBatch) {
|
|
214
|
+
const rows = stmt.all(perBatch, offset) as Record<string, unknown>[];
|
|
215
|
+
if (!rows.length) break;
|
|
216
|
+
const params: unknown[] = [];
|
|
217
|
+
const tuples = rows.map((row) => `(${insertCols.map((c) => { params.push(bindValue(row[c])); return `?${params.length}`; }).join(", ")})`);
|
|
218
|
+
db.run(`INSERT INTO ${quoteIdent(table)} (${insertCols.map(quoteIdent).join(", ")}) VALUES ${tuples.join(", ")}`, params);
|
|
219
|
+
}
|
|
220
|
+
} finally {
|
|
221
|
+
db.exec(`ALTER TABLE ${quoteIdent(table)} ENABLE TRIGGER USER`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Sequences behind BIGSERIAL columns continue after the copied ids.
|
|
225
|
+
for (const seq of db.all<{ column_name: string }>(
|
|
226
|
+
"SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ?1 AND column_default LIKE 'nextval(%'", table,
|
|
227
|
+
)) {
|
|
228
|
+
db.get(`SELECT setval(pg_get_serial_sequence(?1, ?2), COALESCE((SELECT MAX(${quoteIdent(seq.column_name)}) FROM ${quoteIdent(table)}), 0) + 1, false)`,
|
|
229
|
+
table, seq.column_name);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Verify before COMMIT.
|
|
233
|
+
const hashCols = copyRowid ? ["rowid", ...cols] : cols;
|
|
234
|
+
const srcDigest = tableDigest(src.query(`SELECT ${selectCols} FROM ${quoteIdent(table)}`).all() as Record<string, unknown>[], hashCols);
|
|
235
|
+
const tgtDigest = tableDigest(db.all(`SELECT ${hashCols.map(quoteIdent).join(", ")} FROM ${quoteIdent(table)}`), hashCols);
|
|
236
|
+
if (srcDigest.rows !== tgtDigest.rows || srcDigest.sha256 !== tgtDigest.sha256) {
|
|
237
|
+
throw new Error(`verification failed for ${table}: source ${srcDigest.rows} rows ${srcDigest.sha256.slice(0, 12)}, ` +
|
|
238
|
+
`target ${tgtDigest.rows} rows ${tgtDigest.sha256.slice(0, 12)}`);
|
|
239
|
+
}
|
|
240
|
+
report.push({ table, rows: srcDigest.rows, sha256: srcDigest.sha256 });
|
|
241
|
+
log(`[migrate-to-pg] ${table}: ${srcDigest.rows} rows, sha256 ${srcDigest.sha256.slice(0, 16)} ✓`);
|
|
242
|
+
}
|
|
243
|
+
if (opts.dryRun) throw new DryRunRollback("dry run");
|
|
244
|
+
});
|
|
245
|
+
} catch (e) {
|
|
246
|
+
const rows = report.reduce((n, t) => n + t.rows, 0);
|
|
247
|
+
dropEverythingInPublic(db);
|
|
248
|
+
src.close();
|
|
249
|
+
if (!(e instanceof DryRunRollback)) {
|
|
250
|
+
const verify = e instanceof Error && e.message.startsWith("verification failed") ? "MISMATCH" : "NOT-REACHED";
|
|
251
|
+
log(`[migrate-to-pg] SUMMARY result=FAILED tables_verified=${report.length}/${tables.length} rows=${rows} verify=${verify} — rolled back, target left empty`);
|
|
252
|
+
throw e;
|
|
253
|
+
}
|
|
254
|
+
log(`[migrate-to-pg] SUMMARY result=DRY-RUN tables=${report.length} rows=${rows} verify=OK — rolled back, target left empty`);
|
|
255
|
+
return { ok: true, dryRun: true, tables: report };
|
|
256
|
+
}
|
|
257
|
+
src.close();
|
|
258
|
+
log(`[migrate-to-pg] SUMMARY result=COMMITTED tables=${report.length} rows=${report.reduce((n, t) => n + t.rows, 0)} verify=OK`);
|
|
259
|
+
return { ok: true, dryRun: false, tables: report };
|
|
260
|
+
} finally {
|
|
261
|
+
rmSync(snapDir, { recursive: true, force: true });
|
|
262
|
+
}
|
|
263
|
+
}
|
package/src/push.ts
CHANGED
|
@@ -158,6 +158,10 @@ function closeClient(client: SSEClient, reason: string): void {
|
|
|
158
158
|
// we're done with it.
|
|
159
159
|
}
|
|
160
160
|
console.log(`[${ts()}] SSE ✕ ${printableKey(client.key)} closed (reason=${reason})`);
|
|
161
|
+
if (isUserStreamClient(client)) {
|
|
162
|
+
touchUserSeen(client.subscriberUserId!);
|
|
163
|
+
if (!isUserOnline(client.subscriberUserId!)) notifyPresence(client.subscriberUserId!, false);
|
|
164
|
+
}
|
|
161
165
|
}
|
|
162
166
|
|
|
163
167
|
/** Best-effort enqueue with backpressure guard. Returns:
|
|
@@ -308,8 +312,11 @@ function createStreamForKey(
|
|
|
308
312
|
...(opts.eventFilter ? { eventFilter: opts.eventFilter } : {}),
|
|
309
313
|
};
|
|
310
314
|
|
|
315
|
+
const presenceUser = isUserStreamClient(client) ? client.subscriberUserId! : null;
|
|
316
|
+
const wasOnline = presenceUser ? isUserOnline(presenceUser) : true;
|
|
311
317
|
if (!clients.has(key)) clients.set(key, []);
|
|
312
318
|
clients.get(key)!.push(client);
|
|
319
|
+
if (presenceUser) touchUserSeen(presenceUser);
|
|
313
320
|
console.log(`[${ts()}] SSE ← ${printableKey(key)} connected (${clients.get(key)!.length} clients)`);
|
|
314
321
|
|
|
315
322
|
// Send initial connected frame through the backpressure guard so
|
|
@@ -328,6 +335,7 @@ function createStreamForKey(
|
|
|
328
335
|
return;
|
|
329
336
|
}
|
|
330
337
|
const result = tryEnqueueBytes(client, encoder.encode(`: keepalive\n\n`));
|
|
338
|
+
if (result === "ok" && isUserStreamClient(client)) touchUserSeen(client.subscriberUserId!);
|
|
331
339
|
if (result === "dead") {
|
|
332
340
|
// closeClient already cleared the timer; just make sure we
|
|
333
341
|
// also prune from the map.
|
|
@@ -337,6 +345,8 @@ function createStreamForKey(
|
|
|
337
345
|
(client.keepaliveTimer as any)?.unref?.();
|
|
338
346
|
|
|
339
347
|
ensureLivenessSweep();
|
|
348
|
+
// 在线状态的广播放在 connected 帧之后:订阅方先拿到自己的 connected,再看到别人的变化。
|
|
349
|
+
if (presenceUser && !wasOnline) notifyPresence(presenceUser, true);
|
|
340
350
|
},
|
|
341
351
|
cancel() {
|
|
342
352
|
// Consumer side hangup — straightforward path, mark closed and
|
|
@@ -523,6 +533,75 @@ export function pushUserEvent(
|
|
|
523
533
|
if (needPrune) pruneClosed(key);
|
|
524
534
|
}
|
|
525
535
|
|
|
536
|
+
// ── 人类成员在线状态(人员列表的在线点)──────────────────────────────────
|
|
537
|
+
// 判据就是上面这张用户流(/events/users/me)注册表:此刻有活着的用户流 = 在线。按**用户**算,
|
|
538
|
+
// 不按网络 —— 桌面端同一时刻只连当前网络的用户流,按网络算会把正在别的网络里的人画成离线。
|
|
539
|
+
// last_seen 只在内存:连上、每次 keepalive 成功、断开时刷新。Hub 重启后清空(未知 = null,
|
|
540
|
+
// 调用方不要把 null 画成「很久以前」)。
|
|
541
|
+
|
|
542
|
+
const userLastSeen = new Map<string, number>();
|
|
543
|
+
let presenceListener: ((userId: string, online: boolean, lastSeenAt: string | null) => void) | null = null;
|
|
544
|
+
|
|
545
|
+
function isUserStreamClient(client: SSEClient): boolean {
|
|
546
|
+
return !!client.subscriberUserId && client.key.startsWith(USER_KEY_PREFIX);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function touchUserSeen(userId: string): void {
|
|
550
|
+
userLastSeen.set(userId, Date.now());
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function isUserOnline(userId: string): boolean {
|
|
554
|
+
for (const [key, arr] of clients) {
|
|
555
|
+
if (!key.startsWith(USER_KEY_PREFIX)) continue;
|
|
556
|
+
if (arr.some((c) => !c.closed && c.subscriberUserId === userId)) return true;
|
|
557
|
+
}
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function lastSeenIso(userId: string): string | null {
|
|
562
|
+
const ms = userLastSeen.get(userId);
|
|
563
|
+
return ms === undefined ? null : new Date(ms).toISOString();
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function notifyPresence(userId: string, online: boolean): void {
|
|
567
|
+
if (!presenceListener) return;
|
|
568
|
+
try {
|
|
569
|
+
presenceListener(userId, online, lastSeenIso(userId));
|
|
570
|
+
} catch (e: any) {
|
|
571
|
+
console.log(`[${ts()}] presence listener failed: ${e?.message || e}`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** 用户的第一条用户流连上 / 最后一条断开时回调(server.ts 用它向同网络成员推 member_presence)。 */
|
|
576
|
+
export function onUserPresenceChange(cb: ((userId: string, online: boolean, lastSeenAt: string | null) => void) | null): void {
|
|
577
|
+
presenceListener = cb;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** 一批用户此刻的在线状态。鉴权由调用方负责。 */
|
|
581
|
+
export function getUserPresence(userIds: readonly string[]): Map<string, { online: boolean; last_seen_at: string | null }> {
|
|
582
|
+
const online = new Set<string>();
|
|
583
|
+
for (const [key, arr] of clients) {
|
|
584
|
+
if (!key.startsWith(USER_KEY_PREFIX)) continue;
|
|
585
|
+
for (const c of arr) if (!c.closed && c.subscriberUserId) online.add(c.subscriberUserId);
|
|
586
|
+
}
|
|
587
|
+
const out = new Map<string, { online: boolean; last_seen_at: string | null }>();
|
|
588
|
+
for (const uid of userIds) out.set(uid, { online: online.has(uid), last_seen_at: lastSeenIso(uid) });
|
|
589
|
+
return out;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** 推给某网络里所有连着用户流的人(可排除一个)。鉴权由调用方负责。 */
|
|
593
|
+
export function pushUserEventToNetwork(networkId: string, event: Record<string, unknown>, exceptUserId?: string): number {
|
|
594
|
+
const prefix = `${USER_KEY_PREFIX}${networkId}:`;
|
|
595
|
+
const recipients: string[] = [];
|
|
596
|
+
for (const key of clients.keys()) {
|
|
597
|
+
if (!key.startsWith(prefix)) continue;
|
|
598
|
+
const uid = key.slice(prefix.length);
|
|
599
|
+
if (uid && uid !== exceptUserId) recipients.push(uid);
|
|
600
|
+
}
|
|
601
|
+
for (const uid of recipients) pushUserEvent(networkId, uid, event);
|
|
602
|
+
return recipients.length;
|
|
603
|
+
}
|
|
604
|
+
|
|
526
605
|
/**
|
|
527
606
|
* 多用户 Agent 权限:某用户在某网络的授权 / 成员资格变了,断开他在这个网络里**订阅时带了身份**
|
|
528
607
|
* 的流(网络观察流、用户流),客户端重连时按新权限重新鉴权。返回断开的条数。
|
|
@@ -549,6 +628,7 @@ export function __resetSSEClientsForTest(): void {
|
|
|
549
628
|
for (const c of arr) closeClient(c, "reset-for-test");
|
|
550
629
|
}
|
|
551
630
|
clients.clear();
|
|
631
|
+
userLastSeen.clear();
|
|
552
632
|
if (livenessSweepTimer) {
|
|
553
633
|
clearInterval(livenessSweepTimer);
|
|
554
634
|
livenessSweepTimer = null;
|
package/src/server.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
|
|
|
10
10
|
import { z } from "zod/v4";
|
|
11
11
|
import { registerTools } from "./tools.js";
|
|
12
12
|
import { db, logTaskEvent, logAudit, syncScheduledRunForTask } from "./db.js";
|
|
13
|
-
import { createSSEStream, createNetworkObserverStream, createUserEventStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX, closeUserStreamsInNetwork } from "./push.js";
|
|
13
|
+
import { createSSEStream, createNetworkObserverStream, createUserEventStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX, closeUserStreamsInNetwork, getUserPresence, onUserPresenceChange, pushUserEventToNetwork } from "./push.js";
|
|
14
14
|
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
15
15
|
import { pendingInboxCount } from "./inbox-count.js";
|
|
16
16
|
import { addAgentNetworkScope, addHumanNetworkScope, addNetworkScope, addOwnTrafficScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
@@ -238,6 +238,16 @@ function isTmuxAllowedIP(ip: string): boolean {
|
|
|
238
238
|
function isNodeCredential(resolved: { networkId: string | null } | null | undefined): boolean {
|
|
239
239
|
return !!resolved?.networkId;
|
|
240
240
|
}
|
|
241
|
+
// 人员列表的在线点:某人的第一条用户流连上 / 最后一条断开时,推给他所在每个网络里其他连着用户流的成员。
|
|
242
|
+
// 收件人本来就是该网络成员(用户流入口按成员资格鉴权),与 GET /api/networks/:id/humans 同一可见范围。
|
|
243
|
+
// 字段叫 member_user_id:pushUserEvent 会把 user_id 写成**收件人**自己。
|
|
244
|
+
onUserPresenceChange((userId, online, lastSeenAt) => {
|
|
245
|
+
const nets = db.all<{ network_id: string }>("SELECT network_id FROM network_members WHERE user_id = ?1", userId);
|
|
246
|
+
for (const { network_id } of nets) {
|
|
247
|
+
pushUserEventToNetwork(network_id, { type: "member_presence", member_user_id: userId, online, last_seen_at: lastSeenAt }, userId);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
241
251
|
function isHubAdminCredential(resolved: { networkId: string | null; user: { role: string } } | null | undefined): boolean {
|
|
242
252
|
return !!resolved && !resolved.networkId && resolved.user.role === "admin";
|
|
243
253
|
}
|
|
@@ -1606,7 +1616,11 @@ return Bun.serve({
|
|
|
1606
1616
|
if (resolved.user.role !== "admin" && !getUserNetworkRole(resolved.user.user_id, netId)) {
|
|
1607
1617
|
return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
1608
1618
|
}
|
|
1609
|
-
|
|
1619
|
+
const humans = getNetworkHumans(netId);
|
|
1620
|
+
// 在线状态(online / last_seen_at)只给用户令牌:节点令牌拿到的仍是原来的身份字段,不多得任何东西。
|
|
1621
|
+
if (isNodeCredential(resolved)) return withCors(req, Response.json({ ok: true, network_id: netId, humans }));
|
|
1622
|
+
const presence = getUserPresence(humans.map((h) => h.user_id));
|
|
1623
|
+
return withCors(req, Response.json({ ok: true, network_id: netId, humans: humans.map((h) => ({ ...h, ...presence.get(h.user_id)! })) }));
|
|
1610
1624
|
}
|
|
1611
1625
|
|
|
1612
1626
|
// ── V3.13: Network members + invites ──
|
|
@@ -84,6 +84,22 @@ describe("SkillHub real Hub + SQLite", () => {
|
|
|
84
84
|
expect(detail.created_by_user).toBeUndefined(); expect(detail.reviewed_by_user).toBeUndefined(); expect(detail.content_hash).toBeUndefined();
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
+
test("query matches regardless of letter case (SQLite LIKE semantics, kept on PostgreSQL)", async () => {
|
|
88
|
+
for (const query of ["incident", "INCIDENT", "Handoff"]) {
|
|
89
|
+
const found = await tool(nodeToken, "list_skills", { network_id: networkId, query });
|
|
90
|
+
expect(found.skills.map((x: any) => x.skill_id)).toContain(skillId);
|
|
91
|
+
}
|
|
92
|
+
expect((await tool(nodeToken, "list_skills", { network_id: networkId, query: "no-such-skill" })).skills).toHaveLength(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("% and _ in the query are literal characters, not wildcards (#2141)", async () => {
|
|
96
|
+
const ids = async (query: string) => (await tool(nodeToken, "list_skills", { network_id: networkId, query })).skills.map((x: any) => x.skill_id);
|
|
97
|
+
expect(await ids("_")).not.toContain(skillId); // was: matched everything
|
|
98
|
+
expect(await ids("%")).not.toContain(skillId);
|
|
99
|
+
expect(await ids("incident_handoff")).not.toContain(skillId); // `_` must not stand for the "-" in the slug
|
|
100
|
+
expect(await ids("incident-handoff")).toContain(skillId);
|
|
101
|
+
});
|
|
102
|
+
|
|
87
103
|
test("foreign user cannot read or review another network", async () => {
|
|
88
104
|
expect((await tool(foreignToken, "list_skills", { network_id: networkId })).ok).toBe(false);
|
|
89
105
|
const review = await tool(foreignToken, "review_skill", { network_id: networkId, skill_id: skillId, decision: "rejected" });
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { copyFileSync, existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { SQLiteAdapter } from "./db-adapter";
|
|
8
|
+
|
|
9
|
+
// #2151 — a cleanly stopped Hub must leave a complete .db: the -wal is absent
|
|
10
|
+
// or empty, and a copy of the .db file alone still has the data.
|
|
11
|
+
const dir = mkdtempSync(join(tmpdir(), "anet-wal-close-"));
|
|
12
|
+
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
|
13
|
+
|
|
14
|
+
const walBytes = (db: string) => (existsSync(`${db}-wal`) ? statSync(`${db}-wal`).size : 0);
|
|
15
|
+
|
|
16
|
+
function copyOnlyDb(db: string): Database {
|
|
17
|
+
const copy = join(dirname(db), `copy-of-${db.split("/").pop()}`);
|
|
18
|
+
copyFileSync(db, copy); // deliberately without -wal / -shm
|
|
19
|
+
return new Database(copy, { readonly: true });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("#2151 SQLite WAL is checkpointed on close", () => {
|
|
23
|
+
test("SQLiteAdapter.close() after >20 distinct statements leaves a complete .db", () => {
|
|
24
|
+
const path = join(dir, "adapter.db");
|
|
25
|
+
const raw = new Database(path);
|
|
26
|
+
raw.exec("PRAGMA journal_mode=WAL");
|
|
27
|
+
const db = new SQLiteAdapter(raw);
|
|
28
|
+
db.exec("CREATE TABLE t (x TEXT)");
|
|
29
|
+
db.run("INSERT INTO t VALUES (?1)", ["kept"]);
|
|
30
|
+
// bun:sqlite stops being able to close after more than 20 distinct query() strings.
|
|
31
|
+
for (let i = 0; i < 30; i++) db.get(`SELECT ${i} AS v`);
|
|
32
|
+
db.close();
|
|
33
|
+
expect(walBytes(path)).toBe(0);
|
|
34
|
+
const copy = copyOnlyDb(path);
|
|
35
|
+
expect(copy.query("SELECT x FROM t").all()).toEqual([{ x: "kept" }]);
|
|
36
|
+
copy.close();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a real Hub that served traffic and got SIGTERM leaves a complete .db", async () => {
|
|
40
|
+
const bin = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "commhub.ts");
|
|
41
|
+
const home = join(dir, "home");
|
|
42
|
+
const path = join(dir, "hub.db");
|
|
43
|
+
const port = String(27000 + Math.floor(Math.random() * 2000));
|
|
44
|
+
const base = `http://127.0.0.1:${port}`;
|
|
45
|
+
const hub = Bun.spawn(["bun", bin, "--port", port, "--host", "127.0.0.1", "--db", path], {
|
|
46
|
+
env: { PATH: process.env.PATH ?? "", HOME: home }, stdout: "pipe", stderr: "pipe",
|
|
47
|
+
});
|
|
48
|
+
try {
|
|
49
|
+
let up = false;
|
|
50
|
+
for (let i = 0; i < 150 && !up; i++) {
|
|
51
|
+
up = await fetch(`${base}/health`).then((r) => r.ok, () => false);
|
|
52
|
+
if (!up) await Bun.sleep(100);
|
|
53
|
+
}
|
|
54
|
+
expect(up).toBe(true);
|
|
55
|
+
const post = (p: string, body: unknown, token?: string) => fetch(`${base}${p}`, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
58
|
+
body: JSON.stringify(body),
|
|
59
|
+
}).then((r) => r.json() as Promise<any>);
|
|
60
|
+
const reg = await post("/api/auth/register", { username: "walcheck", password: "StrongPassw0rd" });
|
|
61
|
+
expect(reg.ok).toBe(true);
|
|
62
|
+
const login = await post("/api/auth/login", { username: "walcheck", password: "StrongPassw0rd" });
|
|
63
|
+
const net = await post("/api/networks", { name: "wal-net" }, login.token);
|
|
64
|
+
expect(net.ok).toBe(true);
|
|
65
|
+
const ntok = await post("/api/auth/node-token", { network_id: net.network_id, node_name: "wal-agent" }, login.token);
|
|
66
|
+
expect(ntok.ok).toBe(true);
|
|
67
|
+
expect(walBytes(path)).toBeGreaterThan(0); // the writes really are in the WAL before the stop
|
|
68
|
+
} finally {
|
|
69
|
+
hub.kill("SIGTERM");
|
|
70
|
+
await hub.exited;
|
|
71
|
+
}
|
|
72
|
+
expect(walBytes(path)).toBe(0);
|
|
73
|
+
const copy = copyOnlyDb(path);
|
|
74
|
+
expect(copy.query("SELECT COUNT(*) AS n FROM users WHERE username = 'walcheck'").get()).toEqual({ n: 1 });
|
|
75
|
+
expect(copy.query("SELECT COUNT(*) AS n FROM networks WHERE network_name = 'wal-net'").get()).toEqual({ n: 1 });
|
|
76
|
+
copy.close();
|
|
77
|
+
}, 40_000);
|
|
78
|
+
});
|
package/src/tools.ts
CHANGED
|
@@ -357,8 +357,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
357
357
|
FROM skillhub_skills WHERE network_id = ?1`;
|
|
358
358
|
if (!showPending) sql += ` AND status = 'published'`;
|
|
359
359
|
if (query?.trim()) {
|
|
360
|
-
params.push(`%${query.trim()}%`);
|
|
361
|
-
|
|
360
|
+
params.push(`%${query.trim().replace(/[\\%_]/g, (c) => `\\${c}`)}%`); // #2141: the query is text, not a pattern
|
|
361
|
+
const like = db.dialect === "postgres" ? "ILIKE" : "LIKE"; // RFC-039 F6: SQLite LIKE ignores ASCII case; keep that on PG
|
|
362
|
+
sql += ` AND (slug ${like} ?${params.length} ESCAPE '\\' OR name ${like} ?${params.length} ESCAPE '\\' OR description ${like} ?${params.length} ESCAPE '\\')`;
|
|
362
363
|
}
|
|
363
364
|
sql += ` ORDER BY updated_at DESC LIMIT ${limit ?? 100}`;
|
|
364
365
|
return skillHubReply({ ok: true, reviewer, skills: db.all(sql, ...params) });
|