@sleep2agi/commhub-server 0.9.0-preview.70 → 0.9.0-preview.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/package.json +1 -1
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +222 -11
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +8 -0
- package/src/agent-groups-perf.test.ts +190 -0
- package/src/auth.ts +98 -7
- package/src/db-adapter-guard.test.ts +63 -0
- package/src/db-adapter-translate.test.ts +123 -0
- package/src/db-adapter.ts +246 -65
- package/src/db.ts +46 -3
- package/src/pg-worker.ts +87 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +1 -1
- package/src/requirements-mcp.test.ts +6 -0
- package/src/requirements-migrate.ts +61 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +138 -0
- package/src/requirements-seq-http.test.ts +192 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements.ts +139 -23
- package/src/retention.ts +5 -2
- package/src/scheduled-tasks-http.test.ts +93 -29
- package/src/scheduled-tasks.ts +3 -2
- package/src/server.ts +118 -12
- package/src/side-thread-command-transport.test.ts +14 -4
- package/src/side-thread-command-transport.ts +4 -4
- package/src/side-thread.ts +4 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/tools.ts +16 -13
package/src/db-adapter.ts
CHANGED
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
* Database Adapter — supports SQLite and PostgreSQL
|
|
3
3
|
*
|
|
4
4
|
* SQLite adapter: wraps bun:sqlite (sync)
|
|
5
|
-
* PostgreSQL adapter:
|
|
5
|
+
* PostgreSQL adapter: one Bun.SQL connection in a worker thread, bridged to the
|
|
6
|
+
* sync interface with Atomics.wait (pg-worker.ts)
|
|
6
7
|
*
|
|
7
8
|
* Key design: callers write SQLite-style SQL. PgAdapter auto-translates:
|
|
8
9
|
* - ?1, ?2 → $1, $2
|
|
9
|
-
* - datetime('now') →
|
|
10
|
-
*
|
|
11
|
-
* -
|
|
10
|
+
* - datetime('now'[, offset]) → to_char(... AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')
|
|
11
|
+
* (timestamps stay TEXT on both backends, RFC-039 §4.1)
|
|
12
|
+
* - BLOB → BYTEA
|
|
13
|
+
* - INTEGER PRIMARY KEY AUTOINCREMENT → BIGSERIAL PRIMARY KEY; INTEGER → BIGINT
|
|
12
14
|
* - ON CONFLICT(col) DO UPDATE SET → ON CONFLICT(col) DO UPDATE SET (same syntax)
|
|
13
15
|
*/
|
|
14
16
|
|
|
@@ -39,14 +41,32 @@ export interface DbAdapter {
|
|
|
39
41
|
|
|
40
42
|
/** Dialect identifier */
|
|
41
43
|
readonly dialect: "sqlite" | "postgres";
|
|
44
|
+
|
|
45
|
+
/** True when transaction() is all-or-nothing on one connection. */
|
|
46
|
+
readonly atomicTransactions: boolean;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Why features that need atomic transactions (scheduler, runtime evidence,
|
|
50
|
+
* side-thread command outbox) must stay off on this adapter, or null when
|
|
51
|
+
* they may run. Gates refuse on anything but null, so an adapter that does
|
|
52
|
+
* not implement this also refuses.
|
|
53
|
+
*/
|
|
54
|
+
readonly transactionalFeaturesRefusal: string | null;
|
|
42
55
|
}
|
|
43
56
|
|
|
57
|
+
/** RFC-039: opt-in for PostgreSQL features whose PG tests do not exist yet (S4). */
|
|
58
|
+
export const PG_EXPERIMENTAL_ENV = "COMMHUB_PG_EXPERIMENTAL";
|
|
59
|
+
|
|
44
60
|
// ════════════════════════════════════════════
|
|
45
61
|
// SQLite Adapter (bun:sqlite, sync)
|
|
46
62
|
// ════════════════════════════════════════════
|
|
47
63
|
|
|
48
64
|
export class SQLiteAdapter implements DbAdapter {
|
|
49
65
|
readonly dialect = "sqlite" as const;
|
|
66
|
+
readonly atomicTransactions = true;
|
|
67
|
+
get transactionalFeaturesRefusal(): string | null {
|
|
68
|
+
return this.atomicTransactions ? null : "transactions on this adapter are not atomic";
|
|
69
|
+
}
|
|
50
70
|
constructor(private readonly rawDb: Database) {}
|
|
51
71
|
|
|
52
72
|
run(sql: string, params?: any[]): QueryResult {
|
|
@@ -78,6 +98,15 @@ export class SQLiteAdapter implements DbAdapter {
|
|
|
78
98
|
// PostgreSQL Adapter (pg Pool, sync bridge)
|
|
79
99
|
// ════════════════════════════════════════════
|
|
80
100
|
|
|
101
|
+
/**
|
|
102
|
+
* PostgreSQL expression for SQLite's `datetime(...)` text: UTC, `YYYY-MM-DD HH:MM:SS`.
|
|
103
|
+
* Timestamps stay TEXT on both backends (RFC-039 §4.1), so a column written by
|
|
104
|
+
* `datetime('now')` compares, sorts and serialises the same way on each.
|
|
105
|
+
*/
|
|
106
|
+
function pgUtcText(expr: string): string {
|
|
107
|
+
return `to_char((${expr}) AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')`;
|
|
108
|
+
}
|
|
109
|
+
|
|
81
110
|
/**
|
|
82
111
|
* Translate SQLite-style SQL to PostgreSQL.
|
|
83
112
|
* Called on every query — must be fast (simple regex, no parsing).
|
|
@@ -85,18 +114,20 @@ export class SQLiteAdapter implements DbAdapter {
|
|
|
85
114
|
export function sqliteToPostgres(sql: string): string {
|
|
86
115
|
let s = sql;
|
|
87
116
|
// ── datetime translations (before ?N→$N to handle datetime('now', ?N)) ──
|
|
88
|
-
// datetime('now', ?N) → NOW() + $N
|
|
117
|
+
// datetime('now', ?N) → UTC text of NOW() + $N (param contains "+3600 seconds").
|
|
118
|
+
// The CAST gives PG a type for a parameter it could not otherwise infer.
|
|
89
119
|
s = s.replace(/datetime\s*\(\s*'now'\s*,\s*\?(\d+)\s*\)/gi, (_, n) => {
|
|
90
|
-
return `NOW() + $${n}::INTERVAL
|
|
120
|
+
return pgUtcText(`NOW() + CAST($${n} AS TEXT)::INTERVAL`);
|
|
91
121
|
});
|
|
92
|
-
// datetime('now', '+N seconds') → NOW() + INTERVAL 'N seconds'
|
|
122
|
+
// datetime('now', '+N seconds') → UTC text of NOW() + INTERVAL 'N seconds'
|
|
93
123
|
s = s.replace(/datetime\s*\(\s*'now'\s*,\s*'([^']+)'\s*\)/gi, (_, offset) => {
|
|
94
|
-
return `NOW() + INTERVAL '${offset.replace(/^\+/, "")}'
|
|
124
|
+
return pgUtcText(`NOW() + INTERVAL '${offset.replace(/^\+/, "")}'`);
|
|
95
125
|
});
|
|
96
|
-
// datetime('now') → NOW()
|
|
97
|
-
s = s.replace(/datetime\s*\(\s*'now'\s*\)/gi, "NOW()");
|
|
98
|
-
//
|
|
99
|
-
s = s.replace(/
|
|
126
|
+
// datetime('now') → UTC text of NOW()
|
|
127
|
+
s = s.replace(/datetime\s*\(\s*'now'\s*\)/gi, pgUtcText("NOW()"));
|
|
128
|
+
// strftime('%Y-%m-%d %H:%M:%f', 'now') → the same text with milliseconds
|
|
129
|
+
s = s.replace(/strftime\s*\(\s*'%Y-%m-%d %H:%M:%f'\s*,\s*'now'\s*\)/gi,
|
|
130
|
+
"to_char(NOW() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.MS')");
|
|
100
131
|
// ── Parameter placeholders ──
|
|
101
132
|
// ?1, ?2 → $1, $2 (positional params)
|
|
102
133
|
s = s.replace(/\?(\d+)/g, (_, n) => `$${n}`);
|
|
@@ -104,78 +135,168 @@ export function sqliteToPostgres(sql: string): string {
|
|
|
104
135
|
let idx = 0;
|
|
105
136
|
s = s.replace(/\?(?!\d)/g, () => `$${++idx}`);
|
|
106
137
|
// ── DDL translations ──
|
|
107
|
-
// INTEGER PRIMARY KEY AUTOINCREMENT →
|
|
108
|
-
s = s.replace(/INTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT/gi, "
|
|
138
|
+
// INTEGER PRIMARY KEY AUTOINCREMENT → BIGSERIAL PRIMARY KEY
|
|
139
|
+
s = s.replace(/INTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT/gi, "BIGSERIAL PRIMARY KEY");
|
|
140
|
+
// INTEGER → BIGINT: SQLite integers are 64-bit, and the Hub stores epoch
|
|
141
|
+
// milliseconds in INTEGER columns (a 32-bit PG integer overflows).
|
|
142
|
+
s = s.replace(/\bINTEGER\b/gi, "BIGINT");
|
|
143
|
+
// BLOB → BYTEA
|
|
144
|
+
s = s.replace(/\bBLOB\b/gi, "BYTEA");
|
|
109
145
|
return s;
|
|
110
146
|
}
|
|
111
147
|
|
|
112
148
|
/**
|
|
113
|
-
*
|
|
149
|
+
* Split a multi-statement script on top-level `;`.
|
|
150
|
+
*
|
|
151
|
+
* A bare `split(";")` cut through SQL comments (db.ts has `;` inside `--`
|
|
152
|
+
* comments) and through plpgsql `$$ … $$` bodies, so the Hub died on its
|
|
153
|
+
* first schema block on PostgreSQL. This skips `--` and `/* *\/` comments
|
|
154
|
+
* (dropping them) and keeps quoted strings, quoted identifiers and
|
|
155
|
+
* dollar-quoted bodies intact.
|
|
156
|
+
*/
|
|
157
|
+
export function splitSqlStatements(sql: string): string[] {
|
|
158
|
+
const out: string[] = [];
|
|
159
|
+
let cur = "";
|
|
160
|
+
let i = 0;
|
|
161
|
+
const n = sql.length;
|
|
162
|
+
while (i < n) {
|
|
163
|
+
const c = sql[i];
|
|
164
|
+
if (c === "-" && sql[i + 1] === "-") {
|
|
165
|
+
while (i < n && sql[i] !== "\n") i++;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (c === "/" && sql[i + 1] === "*") {
|
|
169
|
+
const close = sql.indexOf("*/", i + 2);
|
|
170
|
+
i = close < 0 ? n : close + 2;
|
|
171
|
+
cur += " ";
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (c === "'" || c === '"') {
|
|
175
|
+
let j = i + 1;
|
|
176
|
+
while (j < n) {
|
|
177
|
+
if (sql[j] === c) {
|
|
178
|
+
if (sql[j + 1] === c) { j += 2; continue; }
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
j++;
|
|
182
|
+
}
|
|
183
|
+
cur += sql.slice(i, j + 1);
|
|
184
|
+
i = j + 1;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (c === "$") {
|
|
188
|
+
const tag = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i));
|
|
189
|
+
if (tag) {
|
|
190
|
+
const close = sql.indexOf(tag[0], i + tag[0].length);
|
|
191
|
+
const stop = close < 0 ? n : close + tag[0].length;
|
|
192
|
+
cur += sql.slice(i, stop);
|
|
193
|
+
i = stop;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (c === ";") {
|
|
198
|
+
if (cur.trim()) out.push(cur.trim());
|
|
199
|
+
cur = "";
|
|
200
|
+
i++;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
cur += c;
|
|
204
|
+
i++;
|
|
205
|
+
}
|
|
206
|
+
if (cur.trim()) out.push(cur.trim());
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* PostgreSQL adapter: synchronous facade over one long-lived worker thread
|
|
212
|
+
* (RFC-039 S2b).
|
|
213
|
+
*
|
|
214
|
+
* The worker (pg-worker.ts) holds a single reserved Bun.SQL connection. Each
|
|
215
|
+
* call posts one request and blocks on Atomics.wait until the worker answers,
|
|
216
|
+
* so the DbAdapter interface stays synchronous — the same contract bun:sqlite
|
|
217
|
+
* gives the rest of the Hub, including "nothing else runs while a transaction
|
|
218
|
+
* is open". Because every statement uses the same backend connection,
|
|
219
|
+
* BEGIN/COMMIT/ROLLBACK are real transactions.
|
|
114
220
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
221
|
+
* SQLite semantics kept on purpose:
|
|
222
|
+
* - a failed statement inside a transaction does not poison the transaction
|
|
223
|
+
* (each statement inside one runs under a savepoint);
|
|
224
|
+
* - nested transaction() calls become savepoints;
|
|
225
|
+
* - booleans bind as 1/0 and undefined as NULL.
|
|
120
226
|
*/
|
|
121
227
|
export class PgAdapter implements DbAdapter {
|
|
122
228
|
readonly dialect = "postgres" as const;
|
|
123
|
-
|
|
229
|
+
readonly atomicTransactions = true;
|
|
230
|
+
/** Closed by default on PG even with real transactions: explicit opt-in only. */
|
|
231
|
+
get transactionalFeaturesRefusal(): string | null {
|
|
232
|
+
if (!this.atomicTransactions) return "transactions on this adapter are not atomic";
|
|
233
|
+
if (process.env[PG_EXPERIMENTAL_ENV] === "1") return null;
|
|
234
|
+
return `PostgreSQL support is experimental; set ${PG_EXPERIMENTAL_ENV}=1 to enable this feature on it (RFC-039)`;
|
|
235
|
+
}
|
|
236
|
+
private readonly port: MessagePort;
|
|
237
|
+
private readonly flag: Int32Array;
|
|
238
|
+
private readonly worker: import("node:worker_threads").Worker;
|
|
239
|
+
private readonly waitMs: number;
|
|
240
|
+
private txDepth = 0;
|
|
124
241
|
|
|
125
242
|
constructor(connectionString: string) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
243
|
+
const { Worker, MessageChannel } = require("node:worker_threads") as typeof import("node:worker_threads");
|
|
244
|
+
const statementTimeoutMs = Number(process.env.COMMHUB_PG_STATEMENT_TIMEOUT_MS || 30_000);
|
|
245
|
+
// The main thread waits a little longer than PG itself will run a statement.
|
|
246
|
+
this.waitMs = statementTimeoutMs > 0 ? statementTimeoutMs + 5_000 : 300_000;
|
|
247
|
+
const { port1, port2 } = new MessageChannel();
|
|
248
|
+
this.port = port1 as unknown as MessagePort;
|
|
249
|
+
this.flag = new Int32Array(new SharedArrayBuffer(4));
|
|
250
|
+
this.worker = new Worker(new URL("./pg-worker.ts", import.meta.url), {
|
|
251
|
+
workerData: { port: port2, flag: this.flag, url: connectionString, statementTimeoutMs },
|
|
252
|
+
transferList: [port2 as any],
|
|
253
|
+
});
|
|
254
|
+
this.worker.unref();
|
|
255
|
+
(port1 as any).unref?.();
|
|
134
256
|
// Test connection on startup
|
|
135
|
-
const test = this.
|
|
257
|
+
const test = this.request("SELECT 1 as ok");
|
|
136
258
|
if (!test.rows?.[0]?.ok) throw new Error("PostgreSQL connection test failed");
|
|
137
259
|
console.log("[commhub] PostgreSQL connection verified");
|
|
138
260
|
}
|
|
139
261
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
p.query(q,v).then(r=>{
|
|
149
|
-
process.stdout.write(JSON.stringify({rows:r.rows,rowCount:r.rowCount||0}));
|
|
150
|
-
p.end();
|
|
151
|
-
}).catch(e=>{
|
|
152
|
-
process.stderr.write(e.message);
|
|
153
|
-
p.end();
|
|
154
|
-
process.exit(1);
|
|
155
|
-
});
|
|
156
|
-
`;
|
|
157
|
-
const proc = Bun.spawnSync(["node", "--no-warnings", "-e", script], {
|
|
158
|
-
stdout: "pipe", stderr: "pipe",
|
|
159
|
-
});
|
|
160
|
-
if (proc.exitCode !== 0) {
|
|
161
|
-
throw new Error(`PG: ${proc.stderr.toString().trim() || "query failed"}`);
|
|
262
|
+
/** Send one already-translated statement to the worker and block for the answer. */
|
|
263
|
+
private request(pgSql: string, params?: any[]): { rows: any[]; count: number } {
|
|
264
|
+
const { receiveMessageOnPort } = require("node:worker_threads") as typeof import("node:worker_threads");
|
|
265
|
+
const bound = (params ?? []).map(v => v === undefined ? null : typeof v === "boolean" ? (v ? 1 : 0) : v);
|
|
266
|
+
Atomics.store(this.flag, 0, 0);
|
|
267
|
+
(this.port as any).postMessage({ sql: pgSql, params: bound, guard: this.txDepth > 0 });
|
|
268
|
+
if (Atomics.wait(this.flag, 0, 0, this.waitMs) === "timed-out") {
|
|
269
|
+
throw new Error(`PG: no answer from the database worker within ${this.waitMs}ms`);
|
|
162
270
|
}
|
|
163
|
-
|
|
271
|
+
const reply = receiveMessageOnPort(this.port as any)?.message as any;
|
|
272
|
+
if (!reply) throw new Error("PG: database worker signalled without a reply");
|
|
273
|
+
if (!reply.ok) throw new Error(`PG: ${reply.error}`);
|
|
274
|
+
return reply;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private querySync(sql: string, params?: any[]): { rows: any[]; count: number } {
|
|
278
|
+
return this.request(sqliteToPostgres(sql), params);
|
|
164
279
|
}
|
|
165
280
|
|
|
166
281
|
run(sql: string, params?: any[]): QueryResult {
|
|
167
282
|
if (sql.trim().toUpperCase().startsWith("PRAGMA")) return { changes: 0 };
|
|
168
283
|
const result = this.querySync(sql, params);
|
|
169
|
-
return { changes: result.
|
|
284
|
+
return { changes: result.count };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** bun:sqlite also accepts get(sql, [a, b]); a lone array argument is the parameter list. */
|
|
288
|
+
private static bindArgs(params: any[]): any[] | undefined {
|
|
289
|
+
if (params.length === 1 && Array.isArray(params[0])) return params[0];
|
|
290
|
+
return params.length > 0 ? params : undefined;
|
|
170
291
|
}
|
|
171
292
|
|
|
172
293
|
get<T = any>(sql: string, ...params: any[]): T | null {
|
|
173
|
-
const result = this.querySync(sql,
|
|
294
|
+
const result = this.querySync(sql, PgAdapter.bindArgs(params));
|
|
174
295
|
return (result.rows?.[0] as T) ?? null;
|
|
175
296
|
}
|
|
176
297
|
|
|
177
298
|
all<T = any>(sql: string, ...params: any[]): T[] {
|
|
178
|
-
const result = this.querySync(sql,
|
|
299
|
+
const result = this.querySync(sql, PgAdapter.bindArgs(params));
|
|
179
300
|
return (result.rows as T[]) ?? [];
|
|
180
301
|
}
|
|
181
302
|
|
|
@@ -183,9 +304,9 @@ export class PgAdapter implements DbAdapter {
|
|
|
183
304
|
if (sql.trim().toUpperCase().startsWith("PRAGMA")) return;
|
|
184
305
|
const pgSql = sqliteToPostgres(sql);
|
|
185
306
|
// Split multi-statement DDL (CREATE TABLE; CREATE INDEX; ...)
|
|
186
|
-
const stmts = pgSql
|
|
307
|
+
const stmts = splitSqlStatements(pgSql);
|
|
187
308
|
for (const stmt of stmts) {
|
|
188
|
-
try { this.
|
|
309
|
+
try { this.request(stmt); } catch (e: any) {
|
|
189
310
|
// Ignore "already exists" errors for CREATE TABLE/INDEX IF NOT EXISTS
|
|
190
311
|
if (!/already exists/.test(e.message)) throw e;
|
|
191
312
|
}
|
|
@@ -193,19 +314,32 @@ export class PgAdapter implements DbAdapter {
|
|
|
193
314
|
}
|
|
194
315
|
|
|
195
316
|
transaction<T>(fn: () => T): T {
|
|
196
|
-
|
|
197
|
-
|
|
317
|
+
const depth = this.txDepth;
|
|
318
|
+
const savepoint = `anet_tx_${depth}`;
|
|
319
|
+
// Control statements run unguarded: they are what the guard is made of.
|
|
320
|
+
this.txDepth = 0;
|
|
321
|
+
try { this.request(depth === 0 ? "BEGIN" : `SAVEPOINT ${savepoint}`); } finally { this.txDepth = depth; }
|
|
322
|
+
this.txDepth = depth + 1;
|
|
323
|
+
let result: T;
|
|
198
324
|
try {
|
|
199
|
-
|
|
200
|
-
this.querySync("COMMIT");
|
|
201
|
-
return result;
|
|
325
|
+
result = fn();
|
|
202
326
|
} catch (e) {
|
|
203
|
-
|
|
327
|
+
this.txDepth = 0;
|
|
328
|
+
try {
|
|
329
|
+
if (depth === 0) this.request("ROLLBACK");
|
|
330
|
+
else { this.request(`ROLLBACK TO SAVEPOINT ${savepoint}`); this.request(`RELEASE SAVEPOINT ${savepoint}`); }
|
|
331
|
+
} catch {}
|
|
332
|
+
this.txDepth = depth;
|
|
204
333
|
throw e;
|
|
205
334
|
}
|
|
335
|
+
this.txDepth = 0;
|
|
336
|
+
try { this.request(depth === 0 ? "COMMIT" : `RELEASE SAVEPOINT ${savepoint}`); } finally { this.txDepth = depth; }
|
|
337
|
+
return result;
|
|
206
338
|
}
|
|
207
339
|
|
|
208
|
-
close(): void {
|
|
340
|
+
close(): void {
|
|
341
|
+
this.worker.terminate();
|
|
342
|
+
}
|
|
209
343
|
}
|
|
210
344
|
|
|
211
345
|
// ════════════════════════════════════════════
|
|
@@ -251,7 +385,54 @@ export function assertSafeTestDatabaseEnv(env: NodeJS.ProcessEnv = process.env):
|
|
|
251
385
|
* explicit COMMHUB_DB (or a reviewed PostgreSQL DATABASE_URL), so forgetting
|
|
252
386
|
* one fails before mkdir/open/write instead of touching the live Hub.
|
|
253
387
|
*/
|
|
388
|
+
/**
|
|
389
|
+
* RFC-039 §9 — the one reviewed way a `bun test` process may use PostgreSQL.
|
|
390
|
+
*
|
|
391
|
+
* The inherited-DATABASE_URL guard stays absolute. A test that means to run
|
|
392
|
+
* on PostgreSQL names a *different* variable, COMMHUB_TEST_PG_URL, which is
|
|
393
|
+
* honoured only under NODE_ENV=test and only for a literal loopback host and
|
|
394
|
+
* a database called `anet_…_test`, so it cannot reach a real Hub database by
|
|
395
|
+
* accident. Error messages never carry credentials.
|
|
396
|
+
*/
|
|
397
|
+
export const TEST_PG_URL_ENV = "COMMHUB_TEST_PG_URL";
|
|
398
|
+
const TEST_PG_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
|
|
399
|
+
const TEST_PG_DB_RE = /^anet_[a-z0-9_]*_test$/;
|
|
400
|
+
const TEST_PG_QUERY_KEYS = new Set(["connect_timeout", "sslmode", "application_name"]);
|
|
401
|
+
|
|
402
|
+
/** A PostgreSQL URL with user and password replaced, safe to print. */
|
|
403
|
+
export function redactPgUrl(raw: string): string {
|
|
404
|
+
try {
|
|
405
|
+
const u = new URL(raw);
|
|
406
|
+
if (u.username || u.password) { u.username = "***"; u.password = ""; }
|
|
407
|
+
return u.toString();
|
|
408
|
+
} catch {
|
|
409
|
+
return "<unparseable URL>";
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function validateTestPgUrl(raw: string): string {
|
|
414
|
+
const refuse = (why: string) => new Error(
|
|
415
|
+
`[commhub] REFUSING ${TEST_PG_URL_ENV}=${redactPgUrl(raw)}: ${why}.\n` +
|
|
416
|
+
" It is test-only: postgres(ql)://<user>:<password>@127.0.0.1|localhost|[::1][:port]/anet_<name>_test",
|
|
417
|
+
);
|
|
418
|
+
let u: URL;
|
|
419
|
+
try { u = new URL(raw); } catch { throw refuse("not a URL"); }
|
|
420
|
+
if (u.protocol !== "postgres:" && u.protocol !== "postgresql:") throw refuse("scheme must be postgres:// or postgresql://");
|
|
421
|
+
if (!TEST_PG_HOSTS.has(u.hostname)) throw refuse("host must be literally 127.0.0.1, localhost or ::1");
|
|
422
|
+
const dbName = decodeURIComponent(u.pathname.replace(/^\//, ""));
|
|
423
|
+
if (!TEST_PG_DB_RE.test(dbName)) throw refuse("database name must match /^anet_[a-z0-9_]*_test$/");
|
|
424
|
+
for (const key of u.searchParams.keys()) {
|
|
425
|
+
if (!TEST_PG_QUERY_KEYS.has(key)) throw refuse(`query parameter "${key}" is not allowed`);
|
|
426
|
+
}
|
|
427
|
+
return raw;
|
|
428
|
+
}
|
|
429
|
+
|
|
254
430
|
function resolveDatabaseTargetAfterGuard(env: NodeJS.ProcessEnv): DbTarget {
|
|
431
|
+
const testPgUrl = env[TEST_PG_URL_ENV];
|
|
432
|
+
if (env.NODE_ENV === "test" && testPgUrl) {
|
|
433
|
+
return { kind: "postgres", url: validateTestPgUrl(testPgUrl) };
|
|
434
|
+
}
|
|
435
|
+
|
|
255
436
|
const dbUrl = env.DATABASE_URL;
|
|
256
437
|
if (dbUrl && (dbUrl.startsWith("postgres://") || dbUrl.startsWith("postgresql://"))) {
|
|
257
438
|
return { kind: "postgres", url: dbUrl };
|
package/src/db.ts
CHANGED
|
@@ -983,7 +983,7 @@ try { db.exec("ALTER TABLE requirements ADD COLUMN client_id TEXT"); } catch {}
|
|
|
983
983
|
try { db.exec("ALTER TABLE requirements ADD COLUMN issues_json TEXT"); } catch {}
|
|
984
984
|
try { db.exec("ALTER TABLE requirements ADD COLUMN tags_json TEXT NOT NULL DEFAULT '[]'"); } catch (e: any) { if (!/duplicate column|already exists/i.test(e?.message || "")) throw e; }
|
|
985
985
|
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_requirements_client ON requirements(network_id, client_id) WHERE client_id IS NOT NULL AND client_id != ''");
|
|
986
|
-
for (const column of ["owner_json TEXT", "participants_json TEXT NOT NULL DEFAULT '[]'", "agent_owner_json TEXT", "description TEXT", "checklist_json TEXT", "project_id TEXT", "external_ref TEXT", "external_url TEXT", "archived INTEGER NOT NULL DEFAULT 0", "created_by_json TEXT", "updated_by_json TEXT", "parent_id TEXT"]) {
|
|
986
|
+
for (const column of ["owner_json TEXT", "participants_json TEXT NOT NULL DEFAULT '[]'", "agent_owner_json TEXT", "description TEXT", "checklist_json TEXT", "project_id TEXT", "external_ref TEXT", "external_url TEXT", "archived INTEGER NOT NULL DEFAULT 0", "created_by_json TEXT", "updated_by_json TEXT", "parent_id TEXT", "start_on TEXT"]) {
|
|
987
987
|
try { db.exec(`ALTER TABLE requirements ADD COLUMN ${column}`); } catch (e: any) { if (!/duplicate column|already exists/i.test(e?.message || "")) throw e; }
|
|
988
988
|
}
|
|
989
989
|
|
|
@@ -1068,6 +1068,43 @@ db.exec(`
|
|
|
1068
1068
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_grants_alias ON network_member_agent_grants(network_id, user_id, alias) WHERE alias IS NOT NULL;
|
|
1069
1069
|
`);
|
|
1070
1070
|
|
|
1071
|
+
// ── Agent 分组(RFC-038 §8):管理员自由定义的组,授权可以给到组,组成员动态生效 ──
|
|
1072
|
+
// 组授权单独一张表(不给 network_member_agent_grants 加列):那张表的 CHECK 改不了,
|
|
1073
|
+
// 而且旧 app 的 PUT 是整体替换 —— 同表的话,旧 app 一保存就把组授权清掉。
|
|
1074
|
+
db.exec(`
|
|
1075
|
+
CREATE TABLE IF NOT EXISTS agent_groups (
|
|
1076
|
+
group_id TEXT PRIMARY KEY,
|
|
1077
|
+
network_id TEXT NOT NULL,
|
|
1078
|
+
name TEXT NOT NULL,
|
|
1079
|
+
description TEXT,
|
|
1080
|
+
created_by TEXT,
|
|
1081
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1082
|
+
updated_at TEXT
|
|
1083
|
+
);
|
|
1084
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_groups_name ON agent_groups(network_id, name);
|
|
1085
|
+
|
|
1086
|
+
CREATE TABLE IF NOT EXISTS agent_group_members (
|
|
1087
|
+
group_id TEXT NOT NULL,
|
|
1088
|
+
node_id TEXT NOT NULL,
|
|
1089
|
+
added_by TEXT,
|
|
1090
|
+
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1091
|
+
PRIMARY KEY (group_id, node_id)
|
|
1092
|
+
);
|
|
1093
|
+
|
|
1094
|
+
CREATE TABLE IF NOT EXISTS network_member_group_grants (
|
|
1095
|
+
network_id TEXT NOT NULL,
|
|
1096
|
+
user_id TEXT NOT NULL,
|
|
1097
|
+
group_id TEXT NOT NULL,
|
|
1098
|
+
can_message INTEGER NOT NULL DEFAULT 1,
|
|
1099
|
+
created_by TEXT,
|
|
1100
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1101
|
+
PRIMARY KEY (network_id, user_id, group_id)
|
|
1102
|
+
);
|
|
1103
|
+
-- 改组成员 / 删组时按组找被授权成员(usersGrantedGroup、granted_user_count):主键以 network_id 开头用不上。
|
|
1104
|
+
-- 展开查询本身走各表主键,见 agent-groups-perf.test.ts 的 EXPLAIN QUERY PLAN。
|
|
1105
|
+
CREATE INDEX IF NOT EXISTS idx_group_grants_group ON network_member_group_grants(group_id);
|
|
1106
|
+
`);
|
|
1107
|
+
|
|
1071
1108
|
// ── #84: node rename — rename_txn table (RFC-010 §4) ──
|
|
1072
1109
|
// Single isolated table holding the rename 2PC transaction state. It doubles
|
|
1073
1110
|
// as the alias_rename_log (RFC §4 risk #5): the audit log of completed renames
|
|
@@ -1475,7 +1512,7 @@ if (db.dialect === "sqlite") {
|
|
|
1475
1512
|
IF NEW.status IN ('replied', 'failed', 'cancelled', 'expired')
|
|
1476
1513
|
AND (TG_OP = 'INSERT' OR OLD.status NOT IN ('replied', 'failed', 'cancelled', 'expired')) THEN
|
|
1477
1514
|
INSERT INTO task_terminal_events (task_id, network_id, from_node_id, completed_at)
|
|
1478
|
-
VALUES (NEW.task_id, NEW.network_id, NEW.from_node_id, COALESCE(NEW.completed_at,
|
|
1515
|
+
VALUES (NEW.task_id, NEW.network_id, NEW.from_node_id, COALESCE(NEW.completed_at, datetime('now')))
|
|
1479
1516
|
ON CONFLICT (task_id) DO NOTHING;
|
|
1480
1517
|
END IF;
|
|
1481
1518
|
RETURN NEW;
|
|
@@ -1970,7 +2007,7 @@ export function syncScheduledRunForTask(taskId: string, expectedNetworkId?: stri
|
|
|
1970
2007
|
`SELECT task_id, network_id, status, completed_at
|
|
1971
2008
|
FROM tasks
|
|
1972
2009
|
WHERE task_id = ?1
|
|
1973
|
-
AND (?2 IS NULL OR network_id = ?2)`,
|
|
2010
|
+
AND (CAST(?2 AS TEXT) IS NULL OR network_id = ?2)`,
|
|
1974
2011
|
taskId,
|
|
1975
2012
|
expectedNetworkId ?? null,
|
|
1976
2013
|
);
|
|
@@ -2036,3 +2073,9 @@ export function logTaskEvent(taskId: string, fromStatus: string | null, toStatus
|
|
|
2036
2073
|
);
|
|
2037
2074
|
} catch {}
|
|
2038
2075
|
}
|
|
2076
|
+
|
|
2077
|
+
// RFC-039 F3: queries break created_at ties with SQLite's implicit rowid
|
|
2078
|
+
// (insertion order). PostgreSQL has none, so give these tables a real one.
|
|
2079
|
+
if (db.dialect === "postgres") {
|
|
2080
|
+
for (const table of ["nodes", "user_inbox"]) db.exec(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS rowid BIGSERIAL`);
|
|
2081
|
+
}
|
package/src/pg-worker.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostgreSQL worker for PgAdapter (RFC-039 S2b).
|
|
3
|
+
*
|
|
4
|
+
* Holds ONE reserved Bun.SQL connection for the life of the Hub, so BEGIN /
|
|
5
|
+
* statements / COMMIT all run on the same backend and transactions are real.
|
|
6
|
+
* The main thread posts one request at a time and blocks on `flag` with
|
|
7
|
+
* Atomics.wait; this worker answers on `port` and then notifies, which keeps
|
|
8
|
+
* DbAdapter synchronous exactly like bun:sqlite.
|
|
9
|
+
*
|
|
10
|
+
* Request: { sql, params?, guard? } guard=true wraps the statement in a
|
|
11
|
+
* savepoint so a failed statement inside a transaction behaves like
|
|
12
|
+
* SQLite (the statement fails, the transaction survives).
|
|
13
|
+
* Response: { ok: true, rows, count } | { ok: false, error, code }
|
|
14
|
+
*/
|
|
15
|
+
import { workerData } from "node:worker_threads";
|
|
16
|
+
import { SQL } from "bun";
|
|
17
|
+
|
|
18
|
+
type Request = { sql: string; params?: unknown[]; guard?: boolean };
|
|
19
|
+
|
|
20
|
+
const { port, flag, url, statementTimeoutMs } = workerData as {
|
|
21
|
+
port: MessagePort;
|
|
22
|
+
flag: Int32Array;
|
|
23
|
+
url: string;
|
|
24
|
+
statementTimeoutMs: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// bigint:true makes int8 arrive as BigInt, so it can be told apart from a
|
|
28
|
+
// TEXT column that happens to hold digits; normalise() turns it into a number.
|
|
29
|
+
// libpq's `connect_timeout` (seconds) in the URL is honoured, default 10 s,
|
|
30
|
+
// so an endpoint that accepts TCP but never speaks PostgreSQL fails the
|
|
31
|
+
// startup check instead of hanging the Hub.
|
|
32
|
+
const connectTimeout = Number(new URL(url).searchParams.get("connect_timeout") || 10);
|
|
33
|
+
const sql = new SQL(url, { max: 1, bigint: true, connectionTimeout: connectTimeout > 0 ? connectTimeout : 10 });
|
|
34
|
+
let conn: any = null;
|
|
35
|
+
|
|
36
|
+
async function connection() {
|
|
37
|
+
if (!conn) {
|
|
38
|
+
conn = await sql.reserve();
|
|
39
|
+
await conn.unsafe("SET TIME ZONE 'UTC'");
|
|
40
|
+
await conn.unsafe(`SET statement_timeout = ${Math.max(0, Math.floor(statementTimeoutMs))}`);
|
|
41
|
+
}
|
|
42
|
+
return conn;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalise(value: unknown): unknown {
|
|
46
|
+
if (typeof value === "bigint") {
|
|
47
|
+
const n = Number(value);
|
|
48
|
+
return Number.isSafeInteger(n) ? n : value.toString();
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function handle(req: Request) {
|
|
54
|
+
const c = await connection();
|
|
55
|
+
if (req.guard) await c.unsafe("SAVEPOINT anet_stmt");
|
|
56
|
+
let result: any;
|
|
57
|
+
try {
|
|
58
|
+
result = await c.unsafe(req.sql, req.params ?? []);
|
|
59
|
+
} catch (e) {
|
|
60
|
+
if (req.guard) {
|
|
61
|
+
await c.unsafe("ROLLBACK TO SAVEPOINT anet_stmt");
|
|
62
|
+
await c.unsafe("RELEASE SAVEPOINT anet_stmt");
|
|
63
|
+
}
|
|
64
|
+
throw e;
|
|
65
|
+
}
|
|
66
|
+
if (req.guard) await c.unsafe("RELEASE SAVEPOINT anet_stmt");
|
|
67
|
+
const rows = [...result].map((row: Record<string, unknown>) => {
|
|
68
|
+
for (const k of Object.keys(row)) row[k] = normalise(row[k]);
|
|
69
|
+
return row;
|
|
70
|
+
});
|
|
71
|
+
return { ok: true, rows, count: typeof result.count === "number" ? result.count : rows.length };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
port.on("message", async (req: Request) => {
|
|
75
|
+
let out: unknown;
|
|
76
|
+
try {
|
|
77
|
+
out = await handle(req);
|
|
78
|
+
} catch (e: any) {
|
|
79
|
+
// A dropped connection is not retried: a retry inside a transaction would
|
|
80
|
+
// replay half of it. Forget it so the next request reconnects.
|
|
81
|
+
if (String(e?.code ?? "").startsWith("ERR_POSTGRES_CONNECTION")) conn = null;
|
|
82
|
+
out = { ok: false, error: String(e?.message ?? e), code: e?.errno ?? e?.code ?? null };
|
|
83
|
+
}
|
|
84
|
+
port.postMessage(out);
|
|
85
|
+
Atomics.store(flag, 0, 1);
|
|
86
|
+
Atomics.notify(flag, 0);
|
|
87
|
+
});
|