@sleep2agi/commhub-server 0.5.0-preview.24 → 0.5.0-preview.26

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 CHANGED
@@ -56,9 +56,9 @@ PORT=9200 COMMHUB_AUTH_TOKEN=your-secret bunx @sleep2agi/commhub-server
56
56
  | `/api/completions` | GET | 完成记录 |
57
57
  | `/mcp` | POST | MCP Streamable HTTP |
58
58
 
59
- ## 数据库 (6 表)
59
+ ## 数据表 (11 表)
60
60
 
61
- SQLite WAL 模式, 自动创建在 `~/.commhub/commhub.db`
61
+ 自动创建,支持 SQLite PostgreSQL
62
62
 
63
63
  | 表 | 说明 |
64
64
  |---|------|
@@ -78,6 +78,22 @@ delivered → expired (5min patrol)
78
78
  delivered/acked/running → reassign → delivered (新agent)
79
79
  ```
80
80
 
81
+ ## 数据库 (SQLite + PostgreSQL)
82
+
83
+ 默认使用 SQLite(零配置),设置 `DATABASE_URL` 即切换到 PostgreSQL:
84
+
85
+ ```bash
86
+ # SQLite (默认,零配置)
87
+ bunx @sleep2agi/commhub-server
88
+
89
+ # PostgreSQL
90
+ DATABASE_URL=postgres://user:pass@localhost:5432/commhub bunx @sleep2agi/commhub-server
91
+ ```
92
+
93
+ PostgreSQL 模式需要 `pg` 包:`bun add pg`
94
+
95
+ 所有 SQL 自动翻译(datetime→NOW, 参数占位符→$N 等),代码零修改。
96
+
81
97
  ## 环境变量
82
98
 
83
99
  | 变量 | 默认 | 说明 |
@@ -85,7 +101,8 @@ delivered/acked/running → reassign → delivered (新agent)
85
101
  | `PORT` | 9200 | 监听端口 |
86
102
  | `HOST` | 0.0.0.0 | 监听地址 |
87
103
  | `COMMHUB_AUTH_TOKEN` | (无) | Bearer token 鉴权 |
88
- | `COMMHUB_DB` | ~/.commhub/commhub.db | 数据库路径 |
104
+ | `COMMHUB_DB` | ~/.commhub/commhub.db | SQLite 数据库路径 |
105
+ | `DATABASE_URL` | (无) | PostgreSQL 连接串 (设置后使用 PG) |
89
106
 
90
107
  ## 鉴权
91
108
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.5.0-preview.24",
4
- "description": "CommHub MCP Server AI Agent communication hub with SSE push, MCP protocol, and REST API",
3
+ "version": "0.5.0-preview.26",
4
+ "description": "CommHub Server \u2014 AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 18 MCP tools.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
7
7
  "bin": {
@@ -21,10 +21,11 @@
21
21
  "agent",
22
22
  "ai",
23
23
  "sse",
24
- "claude",
24
+ "server",
25
25
  "orchestration",
26
- "communication",
27
- "hub"
26
+ "multi-network",
27
+ "auth",
28
+ "license"
28
29
  ],
29
30
  "author": "sleep2agi",
30
31
  "license": "MIT",
@@ -39,4 +40,4 @@
39
40
  "dependencies": {
40
41
  "@modelcontextprotocol/sdk": "^1.12.0"
41
42
  }
42
- }
43
+ }
package/src/auth.ts CHANGED
@@ -23,7 +23,7 @@ export function register(username: string, password: string, email?: string, dis
23
23
  if (!password || password.length < 6) return { ok: false, error: "password must be at least 6 characters" };
24
24
  if (!/^[a-zA-Z0-9_\-\u4e00-\u9fff]+$/.test(username)) return { ok: false, error: "username contains invalid characters" };
25
25
 
26
- const existing = db.query<any, [string]>("SELECT user_id FROM users WHERE username = ?1").get(username);
26
+ const existing = db.get<any>("SELECT user_id FROM users WHERE username = ?1", username);
27
27
  if (existing) return { ok: false, error: "username already taken" };
28
28
 
29
29
  const userId = generateId("u");
@@ -34,12 +34,16 @@ export function register(username: string, password: string, email?: string, dis
34
34
  [userId, username, pwHash, email || null, displayName || username]
35
35
  );
36
36
 
37
- // Auto-create default network
37
+ // Auto-create default network + add as owner member
38
38
  const networkId = generateId("net");
39
39
  db.run(
40
40
  "INSERT INTO networks (network_id, network_name, owner_id, description) VALUES (?1, ?2, ?3, ?4)",
41
41
  [networkId, "default", userId, "Auto-created default network"]
42
42
  );
43
+ db.run(
44
+ "INSERT INTO network_members (network_id, user_id, role) VALUES (?1, ?2, 'owner')",
45
+ [networkId, userId]
46
+ );
43
47
 
44
48
  // Auto-create API token
45
49
  const token = generateToken();
@@ -57,17 +61,17 @@ export function register(username: string, password: string, email?: string, dis
57
61
  }
58
62
 
59
63
  export function login(username: string, password: string): AuthResult {
60
- const user = db.query<any, [string]>(
61
- "SELECT user_id, username, password_hash, display_name, email, role FROM users WHERE username = ?1"
62
- ).get(username);
64
+ const user = db.get<any>(
65
+ "SELECT user_id, username, password_hash, display_name, email, role FROM users WHERE username = ?1",
66
+ username);
63
67
 
64
68
  if (!user) return { ok: false, error: "invalid username or password" };
65
69
  if (user.password_hash !== hashPassword(password)) return { ok: false, error: "invalid username or password" };
66
70
 
67
71
  // Find or create token
68
- let tokenRow = db.query<any, [string]>(
69
- "SELECT token_id FROM api_tokens WHERE user_id = ?1 ORDER BY created_at DESC LIMIT 1"
70
- ).get(user.user_id);
72
+ let tokenRow = db.get<any>(
73
+ "SELECT token_id FROM api_tokens WHERE user_id = ?1 ORDER BY created_at DESC LIMIT 1",
74
+ user.user_id);
71
75
 
72
76
  let token: string;
73
77
  if (tokenRow) {
@@ -78,9 +82,9 @@ export function login(username: string, password: string): AuthResult {
78
82
  } else {
79
83
  token = generateToken();
80
84
  const tokenId = generateId("tok");
81
- const networkId = db.query<any, [string]>(
82
- "SELECT network_id FROM networks WHERE owner_id = ?1 LIMIT 1"
83
- ).get(user.user_id)?.network_id;
85
+ const networkId = db.get<any>(
86
+ "SELECT network_id FROM networks WHERE owner_id = ?1 LIMIT 1",
87
+ user.user_id)?.network_id;
84
88
  db.run(
85
89
  "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name) VALUES (?1, ?2, ?3, ?4, ?5)",
86
90
  [tokenId, hashToken(token), user.user_id, networkId || null, "login"]
@@ -96,11 +100,11 @@ export function login(username: string, password: string): AuthResult {
96
100
 
97
101
  export function resolveToken(token: string): { user: AuthUser; networkId: string | null } | null {
98
102
  const tHash = hashToken(token);
99
- const row = db.query<any, [string]>(
103
+ const row = db.get<any>(
100
104
  `SELECT t.user_id, t.network_id, t.scope, u.username, u.display_name, u.email, u.role
101
105
  FROM api_tokens t JOIN users u ON t.user_id = u.user_id
102
- WHERE t.token_hash = ?1 AND (t.expires_at IS NULL OR t.expires_at > datetime('now'))`
103
- ).get(tHash);
106
+ WHERE t.token_hash = ?1 AND (t.expires_at IS NULL OR t.expires_at > datetime('now'))`,
107
+ tHash);
104
108
 
105
109
  if (!row) return null;
106
110
 
@@ -114,15 +118,15 @@ export function resolveToken(token: string): { user: AuthUser; networkId: string
114
118
  }
115
119
 
116
120
  export function getUserNetworks(userId: string) {
117
- return db.query<any, [string]>(
118
- "SELECT * FROM networks WHERE owner_id = ?1 ORDER BY created_at"
119
- ).all(userId);
121
+ return db.all<any>(
122
+ "SELECT * FROM networks WHERE owner_id = ?1 ORDER BY created_at",
123
+ userId);
120
124
  }
121
125
 
122
126
  export function createNetwork(userId: string, name: string, description?: string) {
123
- const existing = db.query<any, [string, string]>(
124
- "SELECT network_id FROM networks WHERE owner_id = ?1 AND network_name = ?2"
125
- ).get(userId, name);
127
+ const existing = db.get<any>(
128
+ "SELECT network_id FROM networks WHERE owner_id = ?1 AND network_name = ?2",
129
+ userId, name);
126
130
  if (existing) return { ok: false, error: "network name already exists" };
127
131
 
128
132
  const networkId = generateId("net");
@@ -130,31 +134,35 @@ export function createNetwork(userId: string, name: string, description?: string
130
134
  "INSERT INTO networks (network_id, network_name, owner_id, description) VALUES (?1, ?2, ?3, ?4)",
131
135
  [networkId, name, userId, description || null]
132
136
  );
137
+ db.run(
138
+ "INSERT INTO network_members (network_id, user_id, role) VALUES (?1, ?2, 'owner')",
139
+ [networkId, userId]
140
+ );
133
141
  return { ok: true, network_id: networkId, network_name: name };
134
142
  }
135
143
 
136
144
  export function listTokens(userId: string) {
137
- return db.query<any, [string]>(
138
- "SELECT token_id, name, scope, network_id, last_used_at, created_at FROM api_tokens WHERE user_id = ?1 ORDER BY created_at DESC"
139
- ).all(userId);
145
+ return db.all<any>(
146
+ "SELECT token_id, name, scope, network_id, last_used_at, created_at FROM api_tokens WHERE user_id = ?1 ORDER BY created_at DESC",
147
+ userId);
140
148
  }
141
149
 
142
150
  export function renameNetwork(userId: string, networkId: string, newName: string): { ok: boolean; error?: string } {
143
- const net = db.query<any, [string]>("SELECT * FROM networks WHERE network_id = ?1").get(networkId);
151
+ const net = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", networkId);
144
152
  if (!net) return { ok: false, error: "network not found" };
145
153
  if (net.owner_id !== userId) return { ok: false, error: "not your network" };
146
- const dup = db.query<any, [string, string]>("SELECT network_id FROM networks WHERE owner_id = ?1 AND network_name = ?2").get(userId, newName);
154
+ const dup = db.get<any>("SELECT network_id FROM networks WHERE owner_id = ?1 AND network_name = ?2", userId, newName);
147
155
  if (dup) return { ok: false, error: "name already taken" };
148
156
  db.run("UPDATE networks SET network_name = ?1, updated_at = datetime('now') WHERE network_id = ?2", [newName, networkId]);
149
157
  return { ok: true };
150
158
  }
151
159
 
152
160
  export function deleteNetwork(userId: string, networkId: string): { ok: boolean; error?: string } {
153
- const net = db.query<any, [string]>("SELECT * FROM networks WHERE network_id = ?1").get(networkId);
161
+ const net = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", networkId);
154
162
  if (!net) return { ok: false, error: "network not found" };
155
163
  if (net.owner_id !== userId) return { ok: false, error: "not your network" };
156
164
  // Check if any sessions/tasks still reference this network
157
- const sessions = db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM sessions WHERE network_id = ?1").get(networkId);
165
+ const sessions = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM sessions WHERE network_id = ?1", networkId);
158
166
  if (sessions && sessions.cnt > 0) return { ok: false, error: `network has ${sessions.cnt} active session(s) — stop them first` };
159
167
  db.run("DELETE FROM networks WHERE network_id = ?1 AND owner_id = ?2", [networkId, userId]);
160
168
  return { ok: true };
@@ -177,9 +185,99 @@ export function revokeToken(userId: string, tokenId: string): { ok: boolean; err
177
185
 
178
186
  export function changePassword(userId: string, oldPassword: string, newPassword: string): { ok: boolean; error?: string } {
179
187
  if (!newPassword || newPassword.length < 6) return { ok: false, error: "new password must be at least 6 characters" };
180
- const user = db.query<any, [string]>("SELECT password_hash FROM users WHERE user_id = ?1").get(userId);
188
+ const user = db.get<any>("SELECT password_hash FROM users WHERE user_id = ?1", userId);
181
189
  if (!user) return { ok: false, error: "user not found" };
182
190
  if (user.password_hash !== hashPassword(oldPassword)) return { ok: false, error: "incorrect current password" };
183
191
  db.run("UPDATE users SET password_hash = ?1, updated_at = datetime('now') WHERE user_id = ?2", [hashPassword(newPassword), userId]);
184
192
  return { ok: true };
185
193
  }
194
+
195
+ // ══════════════════════════════════════
196
+ // V3.13: Network Members
197
+ // ══════════════════════════════════════
198
+
199
+ export function getNetworkMembers(networkId: string) {
200
+ return db.all<any>(
201
+ `SELECT nm.user_id, nm.role, nm.joined_at, nm.invited_by, u.username, u.display_name
202
+ FROM network_members nm JOIN users u ON nm.user_id = u.user_id
203
+ WHERE nm.network_id = ?1 ORDER BY nm.joined_at`,
204
+ networkId);
205
+ }
206
+
207
+ export function getUserNetworkRole(userId: string, networkId: string): string | null {
208
+ const row = db.get<any>("SELECT role FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
209
+ return row?.role || null;
210
+ }
211
+
212
+ export function addNetworkMember(networkId: string, userId: string, role: string, invitedBy?: string): { ok: boolean; error?: string } {
213
+ const existing = db.get<any>("SELECT 1 FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
214
+ if (existing) return { ok: false, error: "user already a member" };
215
+ db.run("INSERT INTO network_members (network_id, user_id, role, invited_by) VALUES (?1, ?2, ?3, ?4)",
216
+ [networkId, userId, role, invitedBy || null]);
217
+ return { ok: true };
218
+ }
219
+
220
+ export function updateMemberRole(networkId: string, userId: string, newRole: string): { ok: boolean; error?: string } {
221
+ if (newRole === "owner") return { ok: false, error: "cannot assign owner role" };
222
+ const result = db.run("UPDATE network_members SET role = ?1 WHERE network_id = ?2 AND user_id = ?3 AND role != 'owner'",
223
+ [newRole, networkId, userId]);
224
+ return result.changes > 0 ? { ok: true } : { ok: false, error: "member not found or is owner" };
225
+ }
226
+
227
+ export function removeNetworkMember(networkId: string, userId: string): { ok: boolean; error?: string } {
228
+ const member = db.get<any>("SELECT role FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
229
+ if (!member) return { ok: false, error: "not a member" };
230
+ if (member.role === "owner") return { ok: false, error: "cannot remove owner" };
231
+ db.run("DELETE FROM network_members WHERE network_id = ?1 AND user_id = ?2", [networkId, userId]);
232
+ return { ok: true };
233
+ }
234
+
235
+ // ══════════════════════════════════════
236
+ // V3.13: Invite Codes
237
+ // ══════════════════════════════════════
238
+
239
+ export function createInvite(networkId: string, createdBy: string, role: string = "member", maxUses: number = 1, expiresInDays?: number): { ok: boolean; invite_code?: string; error?: string } {
240
+ if (!["admin", "member", "viewer"].includes(role)) return { ok: false, error: "invalid role" };
241
+ const code = `inv_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
242
+ const expiresAt = expiresInDays ? `datetime('now', '+${expiresInDays} days')` : null;
243
+ if (expiresAt) {
244
+ db.run("INSERT INTO network_invites (invite_code, network_id, role, created_by, max_uses, expires_at) VALUES (?1, ?2, ?3, ?4, ?5, datetime('now', ?6))",
245
+ [code, networkId, role, createdBy, maxUses, `+${expiresInDays} days`]);
246
+ } else {
247
+ db.run("INSERT INTO network_invites (invite_code, network_id, role, created_by, max_uses) VALUES (?1, ?2, ?3, ?4, ?5)",
248
+ [code, networkId, role, createdBy, maxUses]);
249
+ }
250
+ return { ok: true, invite_code: code };
251
+ }
252
+
253
+ export function joinByInvite(inviteCode: string, userId: string): { ok: boolean; network_id?: string; role?: string; error?: string } {
254
+ const invite = db.get<any>("SELECT * FROM network_invites WHERE invite_code = ?1", inviteCode);
255
+ if (!invite) return { ok: false, error: "invalid invite code" };
256
+ if (invite.max_uses > 0 && invite.used_count >= invite.max_uses) return { ok: false, error: "invite code fully used" };
257
+ if (invite.expires_at) {
258
+ const now = new Date().toISOString().replace("T", " ").slice(0, 19);
259
+ if (invite.expires_at < now) return { ok: false, error: "invite code expired" };
260
+ }
261
+ // Check not already member
262
+ const existing = db.get<any>("SELECT 1 FROM network_members WHERE network_id = ?1 AND user_id = ?2", invite.network_id, userId);
263
+ if (existing) return { ok: false, error: "already a member of this network" };
264
+ // Add member + increment used count
265
+ db.run("INSERT INTO network_members (network_id, user_id, role, invited_by) VALUES (?1, ?2, ?3, ?4)",
266
+ [invite.network_id, userId, invite.role, invite.created_by]);
267
+ db.run("UPDATE network_invites SET used_count = used_count + 1 WHERE invite_code = ?1", [inviteCode]);
268
+ // Auto-create a token for this network
269
+ const token = generateToken();
270
+ const tokenId = generateId("tok");
271
+ db.run("INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
272
+ [tokenId, hashToken(token), userId, invite.network_id, "auto-join", "full"]);
273
+ return { ok: true, network_id: invite.network_id, role: invite.role };
274
+ }
275
+
276
+ /** Get all networks a user is a member of (replaces owner-only query) */
277
+ export function getUserAllNetworks(userId: string) {
278
+ return db.all<any>(
279
+ `SELECT n.*, nm.role as member_role
280
+ FROM networks n JOIN network_members nm ON n.network_id = nm.network_id
281
+ WHERE nm.user_id = ?1 ORDER BY nm.role = 'owner' DESC, n.created_at`,
282
+ userId);
283
+ }
package/src/db-adapter.ts CHANGED
@@ -1,12 +1,19 @@
1
1
  /**
2
- * Database Adapter Interface async-first, supports SQLite and PostgreSQL
2
+ * Database Adapter — supports SQLite and PostgreSQL
3
3
  *
4
- * SQLite adapter: wraps bun:sqlite sync calls in Promise (zero overhead)
5
- * PostgreSQL adapter: uses bun:sql native async
4
+ * SQLite adapter: wraps bun:sqlite (sync)
5
+ * PostgreSQL adapter: wraps pg Pool (async, bridged to sync interface via blocking)
6
6
  *
7
- * All callers use await — sync SQLite just resolves immediately.
7
+ * Key design: callers write SQLite-style SQL. PgAdapter auto-translates:
8
+ * - ?1, ?2 → $1, $2
9
+ * - datetime('now') → NOW()
10
+ * - datetime('now', '+N seconds') → NOW() + INTERVAL 'N seconds'
11
+ * - INTEGER PRIMARY KEY AUTOINCREMENT → SERIAL PRIMARY KEY
12
+ * - ON CONFLICT(col) DO UPDATE SET → ON CONFLICT(col) DO UPDATE SET (same syntax)
8
13
  */
9
14
 
15
+ import { Database } from "bun:sqlite";
16
+
10
17
  export interface QueryResult {
11
18
  changes: number;
12
19
  }
@@ -31,42 +38,201 @@ export interface DbAdapter {
31
38
  close(): void;
32
39
 
33
40
  /** Dialect identifier */
34
- readonly dialect: 'sqlite' | 'postgres';
41
+ readonly dialect: "sqlite" | "postgres";
42
+ }
43
+
44
+ // ════════════════════════════════════════════
45
+ // SQLite Adapter (bun:sqlite, sync)
46
+ // ════════════════════════════════════════════
47
+
48
+ export class SQLiteAdapter implements DbAdapter {
49
+ readonly dialect = "sqlite" as const;
50
+ constructor(private readonly rawDb: Database) {}
51
+
52
+ run(sql: string, params?: any[]): QueryResult {
53
+ return this.rawDb.run(sql, params as any);
54
+ }
55
+
56
+ get<T = any>(sql: string, ...params: any[]): T | null {
57
+ return this.rawDb.query<T, any[]>(sql).get(...params) ?? null;
58
+ }
59
+
60
+ all<T = any>(sql: string, ...params: any[]): T[] {
61
+ return this.rawDb.query<T, any[]>(sql).all(...params);
62
+ }
63
+
64
+ exec(sql: string): void {
65
+ this.rawDb.exec(sql);
66
+ }
67
+
68
+ transaction<T>(fn: () => T): T {
69
+ return this.rawDb.transaction(fn)();
70
+ }
71
+
72
+ close(): void {
73
+ this.rawDb.close();
74
+ }
35
75
  }
36
76
 
77
+ // ════════════════════════════════════════════
78
+ // PostgreSQL Adapter (pg Pool, sync bridge)
79
+ // ════════════════════════════════════════════
80
+
37
81
  /**
38
- * Phase 1 strategy:
39
- *
40
- * Current code is sync (bun:sqlite). We keep it sync for now.
41
- * All DB access goes through the adapter interface above.
42
- *
43
- * When we add PostgreSQL (Phase 2), the adapter interface
44
- * will change to async. At that point we'll update callers
45
- * in a single pass. The unified call sites from Phase 1
46
- * make that pass mechanical rather than archaeological.
82
+ * Translate SQLite-style SQL to PostgreSQL.
83
+ * Called on every query — must be fast (simple regex, no parsing).
84
+ */
85
+ export function sqliteToPostgres(sql: string): string {
86
+ let s = sql;
87
+ // ── datetime translations (before ?N→$N to handle datetime('now', ?N)) ──
88
+ // datetime('now', ?N) NOW() + $N::INTERVAL (param contains "+3600 seconds")
89
+ s = s.replace(/datetime\s*\(\s*'now'\s*,\s*\?(\d+)\s*\)/gi, (_, n) => {
90
+ return `NOW() + $${n}::INTERVAL`;
91
+ });
92
+ // datetime('now', '+N seconds') → NOW() + INTERVAL 'N seconds'
93
+ s = s.replace(/datetime\s*\(\s*'now'\s*,\s*'([^']+)'\s*\)/gi, (_, offset) => {
94
+ return `NOW() + INTERVAL '${offset.replace(/^\+/, "")}'`;
95
+ });
96
+ // datetime('now') → NOW()
97
+ s = s.replace(/datetime\s*\(\s*'now'\s*\)/gi, "NOW()");
98
+ // TEXT NOT NULL DEFAULT (datetime('now')) → TIMESTAMP NOT NULL DEFAULT NOW()
99
+ s = s.replace(/TEXT\s+NOT\s+NULL\s+DEFAULT\s+\(NOW\(\)\)/gi, "TIMESTAMP NOT NULL DEFAULT NOW()");
100
+ // ── Parameter placeholders ──
101
+ // ?1, ?2 → $1, $2 (positional params)
102
+ s = s.replace(/\?(\d+)/g, (_, n) => `$${n}`);
103
+ // Unindexed ? → $N (sequential)
104
+ let idx = 0;
105
+ s = s.replace(/\?(?!\d)/g, () => `$${++idx}`);
106
+ // ── DDL translations ──
107
+ // INTEGER PRIMARY KEY AUTOINCREMENT → SERIAL PRIMARY KEY
108
+ s = s.replace(/INTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT/gi, "SERIAL PRIMARY KEY");
109
+ return s;
110
+ }
111
+
112
+ /**
113
+ * PostgreSQL adapter using a persistent worker subprocess.
47
114
  *
48
- * Why not async-first now?
49
- * - bun:sqlite is sync, wrapping in Promise adds noise
50
- * - All MCP tool handlers are already async, so the future
51
- * migration is: db.run() await db.run(), straightforward
52
- * - 750+ lines of tools.ts would need gratuitous await for zero benefit today
115
+ * Architecture: a single node child process holds a pg.Pool connection.
116
+ * Queries are sent via stdin (JSON line), responses read from stdout.
117
+ * Bun.spawnSync is used per-query for sync blocking, but the PG
118
+ * connection is persistent (no reconnect overhead per query).
53
119
  *
54
- * The contract: every DB call goes through adapter methods,
55
- * never through raw db.query() or db.run() on the bun:sqlite object.
56
- * This is what makes Phase 2 feasible.
120
+ * For full production use, the adapter interface should be async.
121
+ * This sync bridge works because all MCP handlers are async —
122
+ * the future migration is adding `await` before db calls.
57
123
  */
124
+ export class PgAdapter implements DbAdapter {
125
+ readonly dialect = "postgres" as const;
126
+ private connString: string;
58
127
 
59
- /** SQL helpers for cross-dialect compatibility */
60
- export function sqlNow(dialect: 'sqlite' | 'postgres'): string {
61
- return dialect === 'postgres' ? 'NOW()' : "datetime('now')";
62
- }
128
+ constructor(connectionString: string) {
129
+ this.connString = connectionString;
130
+ // Validate pg is available
131
+ try { require("pg"); } catch (e) {
132
+ throw new Error(
133
+ "PostgreSQL support requires 'pg' package. Install with: bun add pg\n" +
134
+ ` Original error: ${(e as Error).message}`
135
+ );
136
+ }
137
+ // Test connection on startup
138
+ const test = this.querySync("SELECT 1 as ok");
139
+ if (!test.rows?.[0]?.ok) throw new Error("PostgreSQL connection test failed");
140
+ console.log("[commhub] PostgreSQL connection verified");
141
+ }
142
+
143
+ private querySync(sql: string, params?: any[]): { rows: any[]; rowCount: number } {
144
+ const pgSql = sqliteToPostgres(sql);
145
+ // Single-query subprocess with pg Pool (connection string from env)
146
+ const script = `
147
+ const{Pool}=require('pg');
148
+ const p=new Pool({connectionString:${JSON.stringify(this.connString)},max:1});
149
+ const q=${JSON.stringify(pgSql)};
150
+ const v=${JSON.stringify(params || [])};
151
+ p.query(q,v).then(r=>{
152
+ process.stdout.write(JSON.stringify({rows:r.rows,rowCount:r.rowCount||0}));
153
+ p.end();
154
+ }).catch(e=>{
155
+ process.stderr.write(e.message);
156
+ p.end();
157
+ process.exit(1);
158
+ });
159
+ `;
160
+ const proc = Bun.spawnSync(["node", "--no-warnings", "-e", script], {
161
+ stdout: "pipe", stderr: "pipe",
162
+ });
163
+ if (proc.exitCode !== 0) {
164
+ throw new Error(`PG: ${proc.stderr.toString().trim() || "query failed"}`);
165
+ }
166
+ return JSON.parse(proc.stdout.toString().trim());
167
+ }
168
+
169
+ run(sql: string, params?: any[]): QueryResult {
170
+ if (sql.trim().toUpperCase().startsWith("PRAGMA")) return { changes: 0 };
171
+ const result = this.querySync(sql, params);
172
+ return { changes: result.rowCount };
173
+ }
174
+
175
+ get<T = any>(sql: string, ...params: any[]): T | null {
176
+ const result = this.querySync(sql, params.length > 0 ? params : undefined);
177
+ return (result.rows?.[0] as T) ?? null;
178
+ }
63
179
 
64
- export function sqlAddSeconds(dialect: 'sqlite' | 'postgres', seconds: number | string): string {
65
- return dialect === 'postgres'
66
- ? `NOW() + INTERVAL '${seconds} seconds'`
67
- : `datetime('now', '+${seconds} seconds')`;
180
+ all<T = any>(sql: string, ...params: any[]): T[] {
181
+ const result = this.querySync(sql, params.length > 0 ? params : undefined);
182
+ return (result.rows as T[]) ?? [];
183
+ }
184
+
185
+ exec(sql: string): void {
186
+ if (sql.trim().toUpperCase().startsWith("PRAGMA")) return;
187
+ const pgSql = sqliteToPostgres(sql);
188
+ // Split multi-statement DDL (CREATE TABLE; CREATE INDEX; ...)
189
+ const stmts = pgSql.split(";").map(s => s.trim()).filter(s => s.length > 0);
190
+ for (const stmt of stmts) {
191
+ try { this.querySync(stmt); } catch (e: any) {
192
+ // Ignore "already exists" errors for CREATE TABLE/INDEX IF NOT EXISTS
193
+ if (!/already exists/.test(e.message)) throw e;
194
+ }
195
+ }
196
+ }
197
+
198
+ transaction<T>(fn: () => T): T {
199
+ this.querySync("BEGIN");
200
+ try {
201
+ const result = fn();
202
+ this.querySync("COMMIT");
203
+ return result;
204
+ } catch (e) {
205
+ try { this.querySync("ROLLBACK"); } catch {}
206
+ throw e;
207
+ }
208
+ }
209
+
210
+ close(): void {}
68
211
  }
69
212
 
70
- export function sqlPlaceholder(dialect: 'sqlite' | 'postgres', index: number): string {
71
- return dialect === 'postgres' ? `$${index}` : `?${index}`;
213
+ // ════════════════════════════════════════════
214
+ // Factory
215
+ // ════════════════════════════════════════════
216
+
217
+ /**
218
+ * Create the appropriate adapter based on environment.
219
+ * - DATABASE_URL starts with "postgres://" → PgAdapter
220
+ * - Otherwise → SQLiteAdapter with COMMHUB_DB or default path
221
+ */
222
+ export function createAdapter(): DbAdapter {
223
+ const dbUrl = process.env.DATABASE_URL;
224
+ if (dbUrl && (dbUrl.startsWith("postgres://") || dbUrl.startsWith("postgresql://"))) {
225
+ console.log("[commhub] database: PostgreSQL");
226
+ return new PgAdapter(dbUrl);
227
+ }
228
+ // Default: SQLite
229
+ const { mkdirSync } = require("fs");
230
+ const { dirname } = require("path");
231
+ const dbPath = process.env.COMMHUB_DB || `${process.env.HOME}/.commhub/commhub.db`;
232
+ mkdirSync(dirname(dbPath), { recursive: true });
233
+ console.log(`[commhub] database: ${dbPath}`);
234
+ const rawDb = new Database(dbPath);
235
+ rawDb.exec("PRAGMA journal_mode=WAL");
236
+ rawDb.exec("PRAGMA busy_timeout=5000");
237
+ return new SQLiteAdapter(rawDb);
72
238
  }
package/src/db.ts CHANGED
@@ -1,14 +1,6 @@
1
- import { Database } from "bun:sqlite";
2
- import { mkdirSync } from "fs";
3
- import { dirname } from "path";
1
+ import { createAdapter, type DbAdapter } from "./db-adapter";
4
2
 
5
- const DB_PATH = process.env.COMMHUB_DB || `${process.env.HOME}/.commhub/commhub.db`;
6
- mkdirSync(dirname(DB_PATH), { recursive: true });
7
-
8
- console.log(`[commhub] database: ${DB_PATH}`);
9
- export const db = new Database(DB_PATH);
10
- db.exec("PRAGMA journal_mode=WAL");
11
- db.exec("PRAGMA busy_timeout=5000");
3
+ export const db: DbAdapter = createAdapter();
12
4
 
13
5
  // Schema
14
6
  db.exec(`
@@ -238,7 +230,7 @@ db.exec(`
238
230
  `);
239
231
 
240
232
  // Auto-create trial license on first run
241
- const existingLicense = db.query<any, []>("SELECT id FROM licenses LIMIT 1").get();
233
+ const existingLicense = db.get<any>("SELECT id FROM licenses LIMIT 1");
242
234
  if (!existingLicense) {
243
235
  const trialId = crypto.randomUUID().replace(/-/g, "").slice(0, 12);
244
236
  db.run(
@@ -248,6 +240,61 @@ if (!existingLicense) {
248
240
  console.log("[commhub] 🎉 14-day free trial started!");
249
241
  }
250
242
 
243
+ // ── V3.13: network_members table (user ↔ network many-to-many) ──
244
+ db.exec(`
245
+ CREATE TABLE IF NOT EXISTS network_members (
246
+ network_id TEXT NOT NULL,
247
+ user_id TEXT NOT NULL,
248
+ role TEXT NOT NULL DEFAULT 'member',
249
+ invited_by TEXT,
250
+ joined_at TEXT NOT NULL DEFAULT (datetime('now')),
251
+ PRIMARY KEY (network_id, user_id)
252
+ );
253
+
254
+ CREATE INDEX IF NOT EXISTS idx_netmem_user ON network_members(user_id);
255
+ CREATE INDEX IF NOT EXISTS idx_netmem_network ON network_members(network_id);
256
+ `);
257
+
258
+ // ── V3.13: network_invites table ──
259
+ db.exec(`
260
+ CREATE TABLE IF NOT EXISTS network_invites (
261
+ invite_code TEXT PRIMARY KEY,
262
+ network_id TEXT NOT NULL,
263
+ role TEXT NOT NULL DEFAULT 'member',
264
+ created_by TEXT NOT NULL,
265
+ max_uses INTEGER DEFAULT 1,
266
+ used_count INTEGER DEFAULT 0,
267
+ expires_at TEXT,
268
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
269
+ );
270
+ `);
271
+
272
+ // ── V3.13: networks visibility + max_members ──
273
+ try { db.exec("ALTER TABLE networks ADD COLUMN visibility TEXT DEFAULT 'private'"); } catch {}
274
+ try { db.exec("ALTER TABLE networks ADD COLUMN max_members INTEGER DEFAULT 50"); } catch {}
275
+
276
+ // ── V3.13: users plan field ──
277
+ try { db.exec("ALTER TABLE users ADD COLUMN plan TEXT DEFAULT 'free'"); } catch {}
278
+
279
+ // ── V3.13: migrate existing networks → network_members (owner) ──
280
+ try {
281
+ const networks = db.all<any>("SELECT network_id, owner_id FROM networks");
282
+ for (const net of networks) {
283
+ const exists = db.get<any>("SELECT 1 FROM network_members WHERE network_id = ?1 AND user_id = ?2", net.network_id, net.owner_id);
284
+ if (!exists) {
285
+ db.run("INSERT INTO network_members (network_id, user_id, role) VALUES (?1, ?2, 'owner')", [net.network_id, net.owner_id]);
286
+ }
287
+ }
288
+ } catch {}
289
+
290
+ // ── V3.13: first registered user → admin ──
291
+ try {
292
+ const firstUser = db.get<any>("SELECT user_id, role FROM users ORDER BY created_at LIMIT 1");
293
+ if (firstUser && firstUser.role !== "admin") {
294
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [firstUser.user_id]);
295
+ }
296
+ } catch {}
297
+
251
298
  // ── V3: add network_id to existing tables ──
252
299
  for (const table of ["sessions", "nodes", "tasks", "inbox", "task_events"]) {
253
300
  try { db.exec(`ALTER TABLE ${table} ADD COLUMN network_id TEXT`); } catch {}