@sleep2agi/commhub-server 0.5.0-preview.9 → 0.5.0

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.
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Database Adapter — supports SQLite and PostgreSQL
3
+ *
4
+ * SQLite adapter: wraps bun:sqlite (sync)
5
+ * PostgreSQL adapter: wraps pg Pool (async, bridged to sync interface via blocking)
6
+ *
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)
13
+ */
14
+
15
+ import { Database } from "bun:sqlite";
16
+
17
+ export interface QueryResult {
18
+ changes: number;
19
+ }
20
+
21
+ export interface DbAdapter {
22
+ /** Execute a write query (INSERT/UPDATE/DELETE) */
23
+ run(sql: string, params?: any[]): QueryResult;
24
+
25
+ /** Query a single row */
26
+ get<T = any>(sql: string, ...params: any[]): T | null;
27
+
28
+ /** Query multiple rows */
29
+ all<T = any>(sql: string, ...params: any[]): T[];
30
+
31
+ /** Execute raw SQL (DDL) */
32
+ exec(sql: string): void;
33
+
34
+ /** Run a function inside a transaction */
35
+ transaction<T>(fn: () => T): T;
36
+
37
+ /** Close connection */
38
+ close(): void;
39
+
40
+ /** Dialect identifier */
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 params ? this.rawDb.run(sql, params as any) : this.rawDb.run(sql);
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
+ }
75
+ }
76
+
77
+ // ════════════════════════════════════════════
78
+ // PostgreSQL Adapter (pg Pool, sync bridge)
79
+ // ════════════════════════════════════════════
80
+
81
+ /**
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.
114
+ *
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).
119
+ *
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.
123
+ */
124
+ export class PgAdapter implements DbAdapter {
125
+ readonly dialect = "postgres" as const;
126
+ private connString: string;
127
+
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
+ }
179
+
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 {}
211
+ }
212
+
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);
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(`
@@ -54,6 +46,7 @@ db.exec(`
54
46
  artifacts TEXT,
55
47
  score REAL,
56
48
  duration_minutes REAL,
49
+ network_id TEXT,
57
50
  completed_at TEXT NOT NULL DEFAULT (datetime('now'))
58
51
  );
59
52
  `);
@@ -201,13 +194,118 @@ db.exec(`
201
194
  CREATE INDEX IF NOT EXISTS idx_tokens_user ON api_tokens(user_id);
202
195
  `);
203
196
 
197
+ // ── V3: audit_log table ──
198
+ db.exec(`
199
+ CREATE TABLE IF NOT EXISTS audit_log (
200
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
201
+ user_id TEXT,
202
+ username TEXT,
203
+ action TEXT NOT NULL,
204
+ target_type TEXT,
205
+ target_id TEXT,
206
+ detail TEXT,
207
+ ip TEXT,
208
+ network_id TEXT,
209
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
210
+ );
211
+
212
+ CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at DESC);
213
+ CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id);
214
+ CREATE INDEX IF NOT EXISTS idx_audit_network ON audit_log(network_id);
215
+ `);
216
+
217
+ // ── V3: licenses table ──
218
+ db.exec(`
219
+ CREATE TABLE IF NOT EXISTS licenses (
220
+ id TEXT PRIMARY KEY,
221
+ license_key TEXT UNIQUE NOT NULL,
222
+ type TEXT DEFAULT 'trial',
223
+ max_agents INTEGER DEFAULT 5,
224
+ max_networks INTEGER DEFAULT 3,
225
+ max_tasks_day INTEGER DEFAULT 500,
226
+ activated_at TEXT,
227
+ expires_at TEXT,
228
+ owner_id TEXT,
229
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
230
+ );
231
+ `);
232
+
233
+ // Auto-create trial license on first run
234
+ const existingLicense = db.get<any>("SELECT id FROM licenses LIMIT 1");
235
+ if (!existingLicense) {
236
+ const trialId = crypto.randomUUID().replace(/-/g, "").slice(0, 12);
237
+ db.run(
238
+ "INSERT INTO licenses (id, license_key, type, expires_at) VALUES (?1, ?2, 'trial', datetime('now', '+14 days'))",
239
+ [`lic_${trialId}`, `trial-${trialId}`]
240
+ );
241
+ console.log("[commhub] 🎉 14-day free trial started!");
242
+ }
243
+
244
+ // ── V3.13: network_members table (user ↔ network many-to-many) ──
245
+ db.exec(`
246
+ CREATE TABLE IF NOT EXISTS network_members (
247
+ network_id TEXT NOT NULL,
248
+ user_id TEXT NOT NULL,
249
+ role TEXT NOT NULL DEFAULT 'member',
250
+ invited_by TEXT,
251
+ joined_at TEXT NOT NULL DEFAULT (datetime('now')),
252
+ PRIMARY KEY (network_id, user_id)
253
+ );
254
+
255
+ CREATE INDEX IF NOT EXISTS idx_netmem_user ON network_members(user_id);
256
+ CREATE INDEX IF NOT EXISTS idx_netmem_network ON network_members(network_id);
257
+ `);
258
+
259
+ // ── V3.13: network_invites table ──
260
+ db.exec(`
261
+ CREATE TABLE IF NOT EXISTS network_invites (
262
+ invite_code TEXT PRIMARY KEY,
263
+ network_id TEXT NOT NULL,
264
+ role TEXT NOT NULL DEFAULT 'member',
265
+ created_by TEXT NOT NULL,
266
+ max_uses INTEGER DEFAULT 1,
267
+ used_count INTEGER DEFAULT 0,
268
+ expires_at TEXT,
269
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
270
+ );
271
+ `);
272
+
273
+ // ── V3.13: networks visibility + max_members ──
274
+ try { db.exec("ALTER TABLE networks ADD COLUMN visibility TEXT DEFAULT 'private'"); } catch {}
275
+ try { db.exec("ALTER TABLE networks ADD COLUMN max_members INTEGER DEFAULT 50"); } catch {}
276
+
277
+ // ── V3.13: users plan field ──
278
+ try { db.exec("ALTER TABLE users ADD COLUMN plan TEXT DEFAULT 'free'"); } catch {}
279
+
280
+ // ── V3.13: migrate existing networks → network_members (owner) ──
281
+ try {
282
+ const networks = db.all<any>("SELECT network_id, owner_id FROM networks");
283
+ for (const net of networks) {
284
+ const exists = db.get<any>("SELECT 1 FROM network_members WHERE network_id = ?1 AND user_id = ?2", net.network_id, net.owner_id);
285
+ if (!exists) {
286
+ db.run("INSERT INTO network_members (network_id, user_id, role) VALUES (?1, ?2, 'owner')", [net.network_id, net.owner_id]);
287
+ }
288
+ }
289
+ } catch {}
290
+
291
+ // ── V3.13: first registered user → admin ──
292
+ try {
293
+ const firstUser = db.get<any>("SELECT user_id, role FROM users ORDER BY created_at LIMIT 1");
294
+ if (firstUser && firstUser.role !== "admin") {
295
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [firstUser.user_id]);
296
+ }
297
+ } catch {}
298
+
204
299
  // ── V3: add network_id to existing tables ──
205
- for (const table of ["sessions", "nodes", "tasks", "inbox", "task_events"]) {
300
+ for (const table of ["sessions", "nodes", "tasks", "inbox", "task_events", "completions"]) {
206
301
  try { db.exec(`ALTER TABLE ${table} ADD COLUMN network_id TEXT`); } catch {}
207
302
  }
208
303
  try { db.exec("CREATE INDEX IF NOT EXISTS idx_sessions_network ON sessions(network_id)"); } catch {}
209
304
  try { db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_network ON tasks(network_id)"); } catch {}
210
305
  try { db.exec("CREATE INDEX IF NOT EXISTS idx_nodes_network ON nodes(network_id)"); } catch {}
306
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_inbox_network ON inbox(network_id)"); } catch {}
307
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_task_events_network ON task_events(network_id)"); } catch {}
308
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_completions_network ON completions(network_id)"); } catch {}
211
309
 
212
310
  // Helpers
213
311
  export function uuidv4(): string {
@@ -230,10 +328,28 @@ export function generateToken(): string {
230
328
  return `atok_${crypto.randomUUID().replace(/-/g, "")}`;
231
329
  }
232
330
 
331
+ export function generateUserToken(): string {
332
+ return `utok_${crypto.randomUUID().replace(/-/g, "")}`;
333
+ }
334
+
335
+ export function generateNetworkToken(): string {
336
+ return `ntok_${crypto.randomUUID().replace(/-/g, "")}`;
337
+ }
338
+
339
+ export function logAudit(userId: string | null, username: string | null, action: string, targetType?: string, targetId?: string, detail?: string, ip?: string, networkId?: string) {
340
+ try {
341
+ db.run(
342
+ "INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, ip, network_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
343
+ [userId, username, action, targetType ?? null, targetId ?? null, detail ?? null, ip ?? null, networkId ?? null]
344
+ );
345
+ } catch {}
346
+ }
347
+
233
348
  export function logTaskEvent(taskId: string, fromStatus: string | null, toStatus: string, actor: string, detail?: string) {
234
349
  try {
235
350
  db.run(
236
- "INSERT INTO task_events (task_id, from_status, to_status, actor, detail) VALUES (?1, ?2, ?3, ?4, ?5)",
351
+ `INSERT INTO task_events (task_id, from_status, to_status, actor, detail, network_id)
352
+ VALUES (?1, ?2, ?3, ?4, ?5, (SELECT network_id FROM tasks WHERE task_id = ?1))`,
237
353
  [taskId, fromStatus, toStatus, actor, detail ?? null]
238
354
  );
239
355
  } catch {}