postgresai 0.16.0-dev.2 → 0.16.0-dev.4
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/postgres-ai.ts +657 -0
- package/dist/bin/postgres-ai.js +2454 -271
- package/dist/sql/06.helpers.sql +0 -122
- package/dist/sql/sql/06.helpers.sql +0 -122
- package/lib/dblab.ts +428 -0
- package/lib/init.ts +0 -8
- package/lib/joe.ts +739 -0
- package/lib/mcp-server.ts +597 -0
- package/lib/supabase.ts +0 -18
- package/package.json +1 -1
- package/sql/06.helpers.sql +0 -122
- package/test/dblab.cli.test.ts +339 -0
- package/test/dblab.test.ts +415 -0
- package/test/init.integration.test.ts +9 -79
- package/test/joe.cli.test.ts +298 -0
- package/test/joe.test.ts +726 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
import { mkdtempSync } from "fs";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
|
|
6
|
+
// Sync runner for OFFLINE tests only (help / fail-fast). Never use it for a test
|
|
7
|
+
// that round-trips the in-process Bun.serve: spawnSync blocks the JS event loop, so
|
|
8
|
+
// the fake server can't answer and the two deadlock (see runCliAsync).
|
|
9
|
+
function runCli(args: string[], env: Record<string, string> = {}) {
|
|
10
|
+
const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
11
|
+
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
|
|
12
|
+
const result = Bun.spawnSync([bunBin, cliPath, ...args], {
|
|
13
|
+
env: { ...process.env, ...env },
|
|
14
|
+
});
|
|
15
|
+
return {
|
|
16
|
+
status: result.exitCode,
|
|
17
|
+
stdout: new TextDecoder().decode(result.stdout),
|
|
18
|
+
stderr: new TextDecoder().decode(result.stderr),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Async runner for tests that hit the fake server — keeps the event loop free.
|
|
23
|
+
async function runCliAsync(args: string[], env: Record<string, string> = {}) {
|
|
24
|
+
const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
25
|
+
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
|
|
26
|
+
const proc = Bun.spawn([bunBin, cliPath, ...args], {
|
|
27
|
+
env: { ...process.env, ...env },
|
|
28
|
+
stdout: "pipe",
|
|
29
|
+
stderr: "pipe",
|
|
30
|
+
});
|
|
31
|
+
const [status, stdout, stderr] = await Promise.all([
|
|
32
|
+
proc.exited,
|
|
33
|
+
new Response(proc.stdout).text(),
|
|
34
|
+
new Response(proc.stderr).text(),
|
|
35
|
+
]);
|
|
36
|
+
return { status, stdout, stderr };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Isolate config so tests never touch real user state; a shared dir threads sessions. */
|
|
40
|
+
function isolatedEnv(extra: Record<string, string> = {}) {
|
|
41
|
+
const cfgHome = mkdtempSync(resolve(tmpdir(), "joe-cli-test-"));
|
|
42
|
+
return { XDG_CONFIG_HOME: cfgHome, HOME: cfgHome, ...extra };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface RecordedRequest {
|
|
46
|
+
pathname: string;
|
|
47
|
+
body: Record<string, unknown>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Fake PostgREST server for the Joe rpcs (backend not built yet). A `plan` on a
|
|
52
|
+
* project whose id is COLD_PROJECT stays `running` (to exercise the resume path);
|
|
53
|
+
* project id 999 returns the ai_enabled=false PT403; everything else completes.
|
|
54
|
+
*/
|
|
55
|
+
function startFakeApi() {
|
|
56
|
+
const requests: RecordedRequest[] = [];
|
|
57
|
+
const COLD_PROJECT = 77;
|
|
58
|
+
let nextId = 4711;
|
|
59
|
+
const commands = new Map<string, { command: string; projectId: number }>();
|
|
60
|
+
|
|
61
|
+
const server = Bun.serve({
|
|
62
|
+
hostname: "127.0.0.1",
|
|
63
|
+
port: 0,
|
|
64
|
+
async fetch(req) {
|
|
65
|
+
const url = new URL(req.url);
|
|
66
|
+
const bodyText = await req.text();
|
|
67
|
+
let body: Record<string, unknown> = {};
|
|
68
|
+
try {
|
|
69
|
+
body = bodyText ? (JSON.parse(bodyText) as Record<string, unknown>) : {};
|
|
70
|
+
} catch {
|
|
71
|
+
body = {};
|
|
72
|
+
}
|
|
73
|
+
requests.push({ pathname: url.pathname, body });
|
|
74
|
+
|
|
75
|
+
const respond = (obj: unknown, status = 200): Response =>
|
|
76
|
+
new Response(JSON.stringify(obj), { status, headers: { "Content-Type": "application/json" } });
|
|
77
|
+
|
|
78
|
+
if (req.method === "POST" && url.pathname.endsWith("/rpc/projects_list")) {
|
|
79
|
+
return respond([
|
|
80
|
+
{ id: 12, alias: "main-db", name: "Main DB", label: "Main DB", joe_api_v2_enabled: true, tunnel_ready: true },
|
|
81
|
+
{ id: 77, alias: "cold-db", name: "Cold DB", joe_ready: true, tunnel: true },
|
|
82
|
+
]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (req.method === "POST" && url.pathname.endsWith("/rpc/joe_command_submit")) {
|
|
86
|
+
const projectId = Number(body.project_id);
|
|
87
|
+
if (projectId === 999) {
|
|
88
|
+
return respond({ message: "AI features disabled for this org — enable in AI Assistant Settings" }, 403);
|
|
89
|
+
}
|
|
90
|
+
const commandId = String(nextId++);
|
|
91
|
+
commands.set(commandId, { command: String(body.command), projectId });
|
|
92
|
+
// Echo the incoming session_id, or hand back a fresh one for a null session.
|
|
93
|
+
const sessionId = body.session_id != null ? String(body.session_id) : "88";
|
|
94
|
+
return respond({ command_id: commandId, session_id: sessionId, status: "queued" });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (req.method === "POST" && url.pathname.endsWith("/rpc/joe_command_status")) {
|
|
98
|
+
const commandId = String(body.command_id);
|
|
99
|
+
const cmd = commands.get(commandId);
|
|
100
|
+
const status = cmd && cmd.projectId === COLD_PROJECT ? "running" : "done";
|
|
101
|
+
return respond({ command_id: commandId, status, error: null });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (req.method === "POST" && url.pathname.endsWith("/rpc/joe_command_result")) {
|
|
105
|
+
const commandId = String(body.command_id);
|
|
106
|
+
const cmd = commands.get(commandId);
|
|
107
|
+
if (cmd?.command === "exec") {
|
|
108
|
+
return respond({ command_id: commandId, command: "exec", status: "done", row_count: 0, result_rows: [], notices: ["CREATE INDEX"], error: null });
|
|
109
|
+
}
|
|
110
|
+
return respond({
|
|
111
|
+
command_id: commandId,
|
|
112
|
+
status: "done",
|
|
113
|
+
queryid: "7712349901234567890",
|
|
114
|
+
plan_fingerprint: "a1b2c3d4",
|
|
115
|
+
plan_text: "Seq Scan on users (cost=0.00..48210.00 rows=1 width=812)",
|
|
116
|
+
plan_json: { Plan: { "Node Type": "Seq Scan", "Relation Name": "users" } },
|
|
117
|
+
error: null,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return new Response("not found", { status: 404 });
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const baseUrl = `http://${server.hostname}:${server.port}/api/general`;
|
|
126
|
+
return { baseUrl, requests, stop: () => server.stop(true) };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
describe("CLI Joe command surface", () => {
|
|
130
|
+
test("top-level help lists the Joe verbs and projects/result", () => {
|
|
131
|
+
const r = runCli(["--help"], isolatedEnv());
|
|
132
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
133
|
+
for (const verb of ["plan", "explain", "exec", "hypo", "activity", "terminate", "reset", "describe", "projects", "result", "status"]) {
|
|
134
|
+
expect(out).toContain(verb);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("pgai projects prints the brief's table", async () => {
|
|
139
|
+
const api = startFakeApi();
|
|
140
|
+
try {
|
|
141
|
+
const r = await runCliAsync(["projects"], isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl }));
|
|
142
|
+
expect(r.status).toBe(0);
|
|
143
|
+
expect(r.stdout).toContain("PROJECT_ID");
|
|
144
|
+
expect(r.stdout).toContain("ALIAS");
|
|
145
|
+
expect(r.stdout).toContain("JOE");
|
|
146
|
+
expect(r.stdout).toContain("TUNNEL");
|
|
147
|
+
expect(r.stdout).toContain("main-db");
|
|
148
|
+
expect(r.stdout).toContain("ready");
|
|
149
|
+
const listReq = api.requests.find((x) => x.pathname.endsWith("/rpc/projects_list"));
|
|
150
|
+
expect(listReq).toBeDefined();
|
|
151
|
+
} finally {
|
|
152
|
+
api.stop();
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("pgai plan --project 12 submits command='plan' and prints the plan", async () => {
|
|
157
|
+
const api = startFakeApi();
|
|
158
|
+
try {
|
|
159
|
+
const r = await runCliAsync(
|
|
160
|
+
["plan", "select * from users where email = 'x@acme.io'", "--project", "12"],
|
|
161
|
+
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
162
|
+
);
|
|
163
|
+
expect(r.status).toBe(0);
|
|
164
|
+
expect(r.stdout).toContain("Seq Scan on users");
|
|
165
|
+
// client-side plan flag
|
|
166
|
+
expect(r.stdout).toContain("⚑");
|
|
167
|
+
const submit = api.requests.find((x) => x.pathname.endsWith("/rpc/joe_command_submit"));
|
|
168
|
+
expect(submit?.body.command).toBe("plan");
|
|
169
|
+
expect(submit?.body.project_id).toBe(12);
|
|
170
|
+
// numeric project => no projects_list lookup
|
|
171
|
+
expect(api.requests.some((x) => x.pathname.endsWith("/rpc/projects_list"))).toBe(false);
|
|
172
|
+
} finally {
|
|
173
|
+
api.stop();
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("pgai plan --project main-db resolves the alias via projects_list", async () => {
|
|
178
|
+
const api = startFakeApi();
|
|
179
|
+
try {
|
|
180
|
+
const r = await runCliAsync(
|
|
181
|
+
["plan", "select 1", "--project", "main-db"],
|
|
182
|
+
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
183
|
+
);
|
|
184
|
+
expect(r.status).toBe(0);
|
|
185
|
+
expect(api.requests.some((x) => x.pathname.endsWith("/rpc/projects_list"))).toBe(true);
|
|
186
|
+
const submit = api.requests.find((x) => x.pathname.endsWith("/rpc/joe_command_submit"));
|
|
187
|
+
expect(submit?.body.project_id).toBe(12);
|
|
188
|
+
} finally {
|
|
189
|
+
api.stop();
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("optimize loop: plan then exec reuse the same session on one project", async () => {
|
|
194
|
+
const api = startFakeApi();
|
|
195
|
+
const env = isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl });
|
|
196
|
+
try {
|
|
197
|
+
// First command starts a fresh session; the server hands back session 88.
|
|
198
|
+
const first = await runCliAsync(["plan", "select 1", "--project", "12", "--new-session"], env);
|
|
199
|
+
expect(first.status).toBe(0);
|
|
200
|
+
// Second command auto-reuses the stored session (88) on the same project.
|
|
201
|
+
const second = await runCliAsync(["exec", "create index on users (email)", "--project", "12"], env);
|
|
202
|
+
expect(second.status).toBe(0);
|
|
203
|
+
expect(second.stdout).toContain("CREATE INDEX");
|
|
204
|
+
|
|
205
|
+
const submits = api.requests.filter((x) => x.pathname.endsWith("/rpc/joe_command_submit"));
|
|
206
|
+
expect(submits.length).toBe(2);
|
|
207
|
+
// The re-plan/exec threads the session id returned by the first submit.
|
|
208
|
+
expect(submits[0].body.session_id).toBeNull(); // --new-session
|
|
209
|
+
expect(submits[1].body.session_id).toBe("88");
|
|
210
|
+
expect(submits[1].body.command).toBe("exec");
|
|
211
|
+
// exec mints a stable idempotency key.
|
|
212
|
+
expect(typeof submits[1].body.idempotency_key).toBe("string");
|
|
213
|
+
} finally {
|
|
214
|
+
api.stop();
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("cold clone exceeds the budget -> resume handle (exit 0), then pgai result", async () => {
|
|
219
|
+
const api = startFakeApi();
|
|
220
|
+
const env = isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl });
|
|
221
|
+
try {
|
|
222
|
+
// project 77 (COLD) stays `running`; --budget 0 forces immediate resume.
|
|
223
|
+
const r = await runCliAsync(["plan", "select 1", "--project", "77", "--budget", "0"], env);
|
|
224
|
+
expect(r.status).toBe(0);
|
|
225
|
+
expect(r.stdout).toContain("resume:");
|
|
226
|
+
expect(r.stdout).toMatch(/pgai result \d+/);
|
|
227
|
+
|
|
228
|
+
const match = r.stdout.match(/pgai result (\d+)/);
|
|
229
|
+
const commandId = match ? match[1] : "";
|
|
230
|
+
expect(commandId).not.toBe("");
|
|
231
|
+
|
|
232
|
+
const resumed = await runCliAsync(["result", commandId], env);
|
|
233
|
+
expect(resumed.status).toBe(0);
|
|
234
|
+
expect(resumed.stdout).toContain("Seq Scan on users");
|
|
235
|
+
} finally {
|
|
236
|
+
api.stop();
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("ai_enabled=false submit is refused (PT403) with a clear message and exit 1", async () => {
|
|
241
|
+
const api = startFakeApi();
|
|
242
|
+
try {
|
|
243
|
+
const r = await runCliAsync(
|
|
244
|
+
["plan", "select 1", "--project", "999"],
|
|
245
|
+
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
246
|
+
);
|
|
247
|
+
expect(r.status).toBe(1);
|
|
248
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("AI features disabled for this org");
|
|
249
|
+
} finally {
|
|
250
|
+
api.stop();
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("terminate requires a numeric pid and maps args.pid", async () => {
|
|
255
|
+
const api = startFakeApi();
|
|
256
|
+
try {
|
|
257
|
+
const r = await runCliAsync(
|
|
258
|
+
["terminate", "4711", "--project", "12"],
|
|
259
|
+
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
260
|
+
);
|
|
261
|
+
expect(r.status).toBe(0);
|
|
262
|
+
const submit = api.requests.find((x) => x.pathname.endsWith("/rpc/joe_command_submit"));
|
|
263
|
+
expect(submit?.body.command).toBe("terminate");
|
|
264
|
+
expect(submit?.body.args).toEqual({ pid: 4711 });
|
|
265
|
+
expect(submit?.body.sql).toBeNull();
|
|
266
|
+
} finally {
|
|
267
|
+
api.stop();
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("describe maps args.object (and --variant)", async () => {
|
|
272
|
+
const api = startFakeApi();
|
|
273
|
+
try {
|
|
274
|
+
const r = await runCliAsync(
|
|
275
|
+
["describe", "users", "--project", "12", "--variant", "\\d+"],
|
|
276
|
+
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
277
|
+
);
|
|
278
|
+
expect(r.status).toBe(0);
|
|
279
|
+
const submit = api.requests.find((x) => x.pathname.endsWith("/rpc/joe_command_submit"));
|
|
280
|
+
expect(submit?.body.command).toBe("describe");
|
|
281
|
+
expect(submit?.body.args).toEqual({ object: "users", variant: "\\d+" });
|
|
282
|
+
} finally {
|
|
283
|
+
api.stop();
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("missing --project fails fast", () => {
|
|
288
|
+
const r = runCli(["plan", "select 1"], isolatedEnv({ PGAI_API_KEY: "k" }));
|
|
289
|
+
expect(r.status).toBe(1);
|
|
290
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("Project is required");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("missing API key fails fast", () => {
|
|
294
|
+
const r = runCli(["plan", "select 1", "--project", "12"], isolatedEnv());
|
|
295
|
+
expect(r.status).toBe(1);
|
|
296
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
|
|
297
|
+
});
|
|
298
|
+
});
|