postgresai 0.16.0-dev.1 → 0.16.0-dev.11
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/CHANGELOG.md +11 -0
- package/bin/postgres-ai.ts +408 -25
- package/dist/bin/postgres-ai.js +940 -90
- package/dist/sql/06.helpers.sql +0 -122
- package/dist/sql/sql/06.helpers.sql +0 -122
- package/lib/checkup-summary.ts +25 -0
- package/lib/checkup.ts +88 -2
- package/lib/init.ts +29 -8
- package/lib/joe.ts +703 -0
- package/lib/supabase.ts +0 -18
- package/lib/util.ts +241 -18
- package/package.json +1 -1
- package/sql/06.helpers.sql +0 -122
- package/test/auth.test.ts +30 -1
- package/test/checkup.integration.test.ts +31 -21
- package/test/checkup.test.ts +48 -3
- package/test/init.integration.test.ts +9 -79
- package/test/init.test.ts +35 -0
- package/test/joe.cli.test.ts +628 -0
- package/test/joe.test.ts +855 -0
- package/test/monitoring.test.ts +54 -3
- package/test/schema-validation.test.ts +40 -0
- package/test/test-utils.ts +5 -0
- package/test/util.test.ts +227 -1
|
@@ -0,0 +1,628 @@
|
|
|
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. */
|
|
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 sync Joe rpcs. Instance 55 ("cold-db") never
|
|
52
|
+
* posts a result (stays `pending`, exercising the resume path); instance 66
|
|
53
|
+
* ("no-role") returns the Joe-API-v2 role-gate PT403; instance 3 completes.
|
|
54
|
+
* Command ids are ABOVE 2^53 so any parseInt round-trip in the CLI corrupts
|
|
55
|
+
* them and the string-preservation assertions fail.
|
|
56
|
+
*/
|
|
57
|
+
function startFakeApi() {
|
|
58
|
+
const requests: RecordedRequest[] = [];
|
|
59
|
+
const COLD_INSTANCE = 55;
|
|
60
|
+
const FORBIDDEN_INSTANCE = 66;
|
|
61
|
+
const ERROR_INSTANCE = 33;
|
|
62
|
+
const FLAKY_INSTANCE = 44;
|
|
63
|
+
let nextId = 9007199254740993000n; // > Number.MAX_SAFE_INTEGER
|
|
64
|
+
const commands = new Map<string, { command: string; instanceId: number }>();
|
|
65
|
+
|
|
66
|
+
const server = Bun.serve({
|
|
67
|
+
hostname: "127.0.0.1",
|
|
68
|
+
port: 0,
|
|
69
|
+
async fetch(req) {
|
|
70
|
+
const url = new URL(req.url);
|
|
71
|
+
const bodyText = await req.text();
|
|
72
|
+
let body: Record<string, unknown> = {};
|
|
73
|
+
try {
|
|
74
|
+
body = bodyText ? (JSON.parse(bodyText) as Record<string, unknown>) : {};
|
|
75
|
+
} catch {
|
|
76
|
+
body = {};
|
|
77
|
+
}
|
|
78
|
+
requests.push({ pathname: url.pathname, body });
|
|
79
|
+
|
|
80
|
+
const respond = (obj: unknown, status = 200): Response =>
|
|
81
|
+
new Response(JSON.stringify(obj), { status, headers: { "Content-Type": "application/json" } });
|
|
82
|
+
|
|
83
|
+
if (req.method === "POST" && url.pathname.endsWith("/rpc/projects_list")) {
|
|
84
|
+
return respond([
|
|
85
|
+
{ project_id: 12, alias: "main-db", name: "Main DB", joe_ready: true, tunnel: true, instance_id: 3 },
|
|
86
|
+
{ project_id: 77, alias: "cold-db", name: "Cold DB", joe_ready: true, tunnel: true, instance_id: COLD_INSTANCE },
|
|
87
|
+
{ project_id: 99, alias: "no-role", name: "No Role", joe_ready: true, tunnel: true, instance_id: FORBIDDEN_INSTANCE },
|
|
88
|
+
{ project_id: 14, alias: "no-joe", name: "No Joe", joe_ready: false, tunnel: false, instance_id: null },
|
|
89
|
+
{ project_id: 15, alias: "err-db", name: "Err DB", joe_ready: true, tunnel: true, instance_id: ERROR_INSTANCE },
|
|
90
|
+
{ project_id: 16, alias: "flaky-db", name: "Flaky DB", joe_ready: true, tunnel: true, instance_id: FLAKY_INSTANCE },
|
|
91
|
+
]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (req.method === "POST" && url.pathname.endsWith("/rpc/joe_command_run")) {
|
|
95
|
+
const instanceId = Number(body.instance_id);
|
|
96
|
+
if (instanceId === FORBIDDEN_INSTANCE) {
|
|
97
|
+
return respond(
|
|
98
|
+
{ code: "PT403", message: "Forbidden", details: "Joe API v2 requires the All Features role." },
|
|
99
|
+
403
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
const commandId = String(nextId++);
|
|
103
|
+
commands.set(commandId, { command: String(body.command), instanceId });
|
|
104
|
+
// The rpc returns the id as a bare JSON string.
|
|
105
|
+
return respond(commandId);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (req.method === "POST" && url.pathname.endsWith("/rpc/joe_command_output")) {
|
|
109
|
+
const commandId = String(body.command_id);
|
|
110
|
+
if (commandId === "424242") {
|
|
111
|
+
// Terminal failure without a structured row (e.g. clone init failed).
|
|
112
|
+
return respond({ command_id: commandId, status: "error", created_at: "2026-07-22T10:00:00", error: "ERROR: something failed" });
|
|
113
|
+
}
|
|
114
|
+
if (commandId === "424244") {
|
|
115
|
+
return respond({ command_id: commandId, status: "pending", created_at: "2026-07-22T10:00:00" });
|
|
116
|
+
}
|
|
117
|
+
const cmd = commands.get(commandId);
|
|
118
|
+
if (!cmd) {
|
|
119
|
+
return respond({ code: "PT404", message: "Not found", details: "Specified command not found." }, 404);
|
|
120
|
+
}
|
|
121
|
+
if (cmd.instanceId === COLD_INSTANCE) {
|
|
122
|
+
return respond({ command_id: commandId, status: "pending", created_at: "2026-07-22T10:00:00" });
|
|
123
|
+
}
|
|
124
|
+
if (cmd.instanceId === ERROR_INSTANCE) {
|
|
125
|
+
// Joe posted a terminal failure for this command.
|
|
126
|
+
return respond({
|
|
127
|
+
command_id: commandId,
|
|
128
|
+
status: "error",
|
|
129
|
+
created_at: "2026-07-22T10:00:00",
|
|
130
|
+
queryid: null,
|
|
131
|
+
plan_json: null,
|
|
132
|
+
plan_text: null,
|
|
133
|
+
error: "ERROR: relation \"nope\" does not exist",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
if (cmd.instanceId === FLAKY_INSTANCE) {
|
|
137
|
+
// The output endpoint is down (proxy hiccup / pod restart) — every
|
|
138
|
+
// poll 502s while the command id remains perfectly valid.
|
|
139
|
+
return new Response("Bad Gateway", { status: 502 });
|
|
140
|
+
}
|
|
141
|
+
if (cmd.command.startsWith("exec ") || cmd.command.startsWith("\\d")) {
|
|
142
|
+
return respond({
|
|
143
|
+
command_id: commandId,
|
|
144
|
+
status: "ok",
|
|
145
|
+
created_at: "2026-07-22T10:00:00",
|
|
146
|
+
command: cmd.command.split(" ")[0],
|
|
147
|
+
query: null,
|
|
148
|
+
queryid: null,
|
|
149
|
+
response: cmd.command.startsWith("exec ") ? "CREATE INDEX" : "Table \"public.users\"",
|
|
150
|
+
plan_text: null,
|
|
151
|
+
plan_json: null,
|
|
152
|
+
plan_execution_text: null,
|
|
153
|
+
plan_execution_json: null,
|
|
154
|
+
stats: null,
|
|
155
|
+
recommendations: null,
|
|
156
|
+
error: null,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return respond({
|
|
160
|
+
command_id: commandId,
|
|
161
|
+
status: "ok",
|
|
162
|
+
created_at: "2026-07-22T10:00:00",
|
|
163
|
+
command: cmd.command.split(" ")[0],
|
|
164
|
+
query: cmd.command.replace(/^\S+\s*/, "") || null,
|
|
165
|
+
queryid: "7712349901234567890",
|
|
166
|
+
response: null,
|
|
167
|
+
plan_text: "Seq Scan on users (cost=0.00..48210.00 rows=1 width=812)",
|
|
168
|
+
plan_json: [{ Plan: { "Node Type": "Seq Scan", "Relation Name": "users" } }],
|
|
169
|
+
plan_execution_text: "Seq Scan on users (actual time=0.05..12.30 rows=1 loops=1)",
|
|
170
|
+
plan_execution_json: [{ Plan: {} }],
|
|
171
|
+
stats: "Time: 12.4 ms",
|
|
172
|
+
recommendations: null,
|
|
173
|
+
error: null,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return new Response("not found", { status: 404 });
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const baseUrl = `http://${server.hostname}:${server.port}/api/general`;
|
|
182
|
+
return { baseUrl, requests, stop: () => server.stop(true) };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
describe("CLI Joe command surface (grouped under `pgai joe …`)", () => {
|
|
186
|
+
test("top-level help lists the joe group and top-level projects", () => {
|
|
187
|
+
const r = runCli(["--help"], isolatedEnv());
|
|
188
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
189
|
+
expect(out).toContain("joe");
|
|
190
|
+
expect(out).toContain("projects");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("pgai joe --help lists all the Joe verbs (and no async-era flags)", () => {
|
|
194
|
+
const r = runCli(["joe", "--help"], isolatedEnv());
|
|
195
|
+
expect(r.status).toBe(0);
|
|
196
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
197
|
+
for (const verb of ["plan", "explain", "exec", "hypo", "activity", "terminate", "reset", "describe", "result"]) {
|
|
198
|
+
expect(out).toContain(verb);
|
|
199
|
+
}
|
|
200
|
+
// The async surface's session flags and status/history verbs are gone.
|
|
201
|
+
expect(out).not.toContain("--session");
|
|
202
|
+
expect(out).not.toContain("new-session");
|
|
203
|
+
expect(out).not.toContain("history");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("pgai joe plan --help offers --instance-id and --budget but not --session", () => {
|
|
207
|
+
const r = runCli(["joe", "plan", "--help"], isolatedEnv());
|
|
208
|
+
expect(r.status).toBe(0);
|
|
209
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
210
|
+
expect(out).toContain("--instance-id");
|
|
211
|
+
expect(out).toContain("--budget");
|
|
212
|
+
expect(out).toContain("--project");
|
|
213
|
+
expect(out).not.toContain("--session");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("pgai projects prints the table", async () => {
|
|
217
|
+
const api = startFakeApi();
|
|
218
|
+
try {
|
|
219
|
+
const r = await runCliAsync(["projects"], isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl }));
|
|
220
|
+
expect(r.status).toBe(0);
|
|
221
|
+
expect(r.stdout).toContain("PROJECT_ID");
|
|
222
|
+
expect(r.stdout).toContain("ALIAS");
|
|
223
|
+
expect(r.stdout).toContain("JOE");
|
|
224
|
+
expect(r.stdout).toContain("TUNNEL");
|
|
225
|
+
expect(r.stdout).toContain("main-db");
|
|
226
|
+
expect(r.stdout).toContain("ready");
|
|
227
|
+
const listReq = api.requests.find((x) => x.pathname.endsWith("/rpc/projects_list"));
|
|
228
|
+
expect(listReq).toBeDefined();
|
|
229
|
+
} finally {
|
|
230
|
+
api.stop();
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("pgai projects --json prints a machine-readable project array", async () => {
|
|
235
|
+
const api = startFakeApi();
|
|
236
|
+
try {
|
|
237
|
+
const r = await runCliAsync(
|
|
238
|
+
["projects", "--json"],
|
|
239
|
+
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
240
|
+
);
|
|
241
|
+
expect(r.status).toBe(0);
|
|
242
|
+
const projects = JSON.parse(r.stdout) as Array<{ project_id: number; alias: string }>;
|
|
243
|
+
expect(projects).toBeArray();
|
|
244
|
+
expect(projects[0]).toMatchObject({ project_id: 12, alias: "main-db" });
|
|
245
|
+
} finally {
|
|
246
|
+
api.stop();
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("pgai joe plan --project 12 sends the RAW text against the project's instance", async () => {
|
|
251
|
+
const api = startFakeApi();
|
|
252
|
+
try {
|
|
253
|
+
const r = await runCliAsync(
|
|
254
|
+
["joe", "plan", "select * from users where email = 'x@acme.io'", "--project", "12"],
|
|
255
|
+
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
256
|
+
);
|
|
257
|
+
expect(r.status).toBe(0);
|
|
258
|
+
expect(r.stdout).toContain("Seq Scan on users");
|
|
259
|
+
// client-side plan flag from the structured plan_json
|
|
260
|
+
expect(r.stdout).toContain("⚑");
|
|
261
|
+
const run = api.requests.find((x) => x.pathname.endsWith("/rpc/joe_command_run"));
|
|
262
|
+
// The run rpc keys on the INSTANCE id (3), resolved from project 12, and
|
|
263
|
+
// the command is the raw text with the verb prefix — no structured body.
|
|
264
|
+
expect(run?.body).toEqual({
|
|
265
|
+
instance_id: 3,
|
|
266
|
+
command: "plan select * from users where email = 'x@acme.io'",
|
|
267
|
+
});
|
|
268
|
+
// A numeric project still needs the listing — the instance id lives there.
|
|
269
|
+
expect(api.requests.some((x) => x.pathname.endsWith("/rpc/projects_list"))).toBe(true);
|
|
270
|
+
} finally {
|
|
271
|
+
api.stop();
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("pgai joe plan --json prints the full output row as JSON", async () => {
|
|
276
|
+
const api = startFakeApi();
|
|
277
|
+
try {
|
|
278
|
+
const r = await runCliAsync(
|
|
279
|
+
["joe", "plan", "select 1", "--project", "12", "--json"],
|
|
280
|
+
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
281
|
+
);
|
|
282
|
+
expect(r.status).toBe(0);
|
|
283
|
+
const result = JSON.parse(r.stdout) as {
|
|
284
|
+
command_id: string;
|
|
285
|
+
status: string;
|
|
286
|
+
plan_text: string;
|
|
287
|
+
plan_execution_text: string;
|
|
288
|
+
stats: string;
|
|
289
|
+
plan_json: unknown;
|
|
290
|
+
queryid: string;
|
|
291
|
+
};
|
|
292
|
+
expect(result.command_id).toMatch(/^\d+$/);
|
|
293
|
+
expect(result.status).toBe("ok");
|
|
294
|
+
expect(result.plan_text).toContain("Seq Scan on users");
|
|
295
|
+
// A >2^53 queryid survives the output rpc round-trip VERBATIM as a string.
|
|
296
|
+
expect(result.queryid).toBe("7712349901234567890");
|
|
297
|
+
// The sync contract surfaces the FULL body — execution plan and stats too.
|
|
298
|
+
expect(result.plan_execution_text).toContain("actual time");
|
|
299
|
+
expect(result.stats).toBe("Time: 12.4 ms");
|
|
300
|
+
expect(Array.isArray(result.plan_json)).toBe(true);
|
|
301
|
+
} finally {
|
|
302
|
+
api.stop();
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("pgai joe plan --project main-db resolves the alias via projects_list", async () => {
|
|
307
|
+
const api = startFakeApi();
|
|
308
|
+
try {
|
|
309
|
+
const r = await runCliAsync(
|
|
310
|
+
["joe", "plan", "select 1", "--project", "main-db"],
|
|
311
|
+
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
312
|
+
);
|
|
313
|
+
expect(r.status).toBe(0);
|
|
314
|
+
expect(api.requests.some((x) => x.pathname.endsWith("/rpc/projects_list"))).toBe(true);
|
|
315
|
+
const run = api.requests.find((x) => x.pathname.endsWith("/rpc/joe_command_run"));
|
|
316
|
+
expect(run?.body.instance_id).toBe(3);
|
|
317
|
+
} finally {
|
|
318
|
+
api.stop();
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("every verb maps to its raw Joe command text", async () => {
|
|
323
|
+
const cases: Array<{ argv: string[]; text: string }> = [
|
|
324
|
+
{ argv: ["joe", "explain", "select 1", "--project", "12"], text: "explain select 1" },
|
|
325
|
+
{ argv: ["joe", "exec", "create index on users (email)", "--project", "12"], text: "exec create index on users (email)" },
|
|
326
|
+
{ argv: ["joe", "hypo", "create index on users (email)", "--project", "12"], text: "hypo create index on users (email)" },
|
|
327
|
+
{ argv: ["joe", "activity", "--project", "12"], text: "activity" },
|
|
328
|
+
{ argv: ["joe", "reset", "--project", "12"], text: "reset" },
|
|
329
|
+
{ argv: ["joe", "terminate", "4711", "--project", "12"], text: "terminate 4711" },
|
|
330
|
+
{ argv: ["joe", "describe", "users", "--project", "12"], text: "\\d users" },
|
|
331
|
+
{ argv: ["joe", "describe", "users", "--project", "12", "--variant", "\\d+"], text: "\\d+ users" },
|
|
332
|
+
];
|
|
333
|
+
for (const c of cases) {
|
|
334
|
+
const api = startFakeApi();
|
|
335
|
+
try {
|
|
336
|
+
const r = await runCliAsync(c.argv, isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl }));
|
|
337
|
+
expect(r.status).toBe(0);
|
|
338
|
+
const run = api.requests.find((x) => x.pathname.endsWith("/rpc/joe_command_run"));
|
|
339
|
+
expect(run?.body.command).toBe(c.text);
|
|
340
|
+
expect(run?.body.instance_id).toBe(3);
|
|
341
|
+
} finally {
|
|
342
|
+
api.stop();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("pgai joe explain --instance-id targets the instance directly (no projects_list)", async () => {
|
|
348
|
+
// The v1 path: projects_list is not deployed on main/.green, so the joe
|
|
349
|
+
// verbs must be fully usable with a manual instance id — resolution is
|
|
350
|
+
// skipped entirely and the id goes on the wire as the given string.
|
|
351
|
+
const api = startFakeApi();
|
|
352
|
+
try {
|
|
353
|
+
const r = await runCliAsync(
|
|
354
|
+
["joe", "explain", "select 1", "--instance-id", "1"],
|
|
355
|
+
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
356
|
+
);
|
|
357
|
+
expect(r.status).toBe(0);
|
|
358
|
+
const run = api.requests.find((x) => x.pathname.endsWith("/rpc/joe_command_run"));
|
|
359
|
+
expect(run?.body).toEqual({ instance_id: "1", command: "explain select 1" });
|
|
360
|
+
expect(api.requests.some((x) => x.pathname.endsWith("/rpc/projects_list"))).toBe(false);
|
|
361
|
+
} finally {
|
|
362
|
+
api.stop();
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("exec surfaces Joe's response body", async () => {
|
|
367
|
+
const api = startFakeApi();
|
|
368
|
+
try {
|
|
369
|
+
const r = await runCliAsync(
|
|
370
|
+
["joe", "exec", "create index on users (email)", "--project", "12"],
|
|
371
|
+
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
372
|
+
);
|
|
373
|
+
expect(r.status).toBe(0);
|
|
374
|
+
expect(r.stdout).toContain("CREATE INDEX");
|
|
375
|
+
} finally {
|
|
376
|
+
api.stop();
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("cold clone exceeds the budget -> resume handle (exit 0), then pgai joe result", async () => {
|
|
381
|
+
const api = startFakeApi();
|
|
382
|
+
const env = isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl });
|
|
383
|
+
try {
|
|
384
|
+
// cold-db (instance 55) stays `pending`; --budget 0 forces immediate resume.
|
|
385
|
+
const r = await runCliAsync(["joe", "plan", "select 1", "--project", "cold-db", "--budget", "0"], env);
|
|
386
|
+
expect(r.status).toBe(0);
|
|
387
|
+
expect(r.stdout).toContain("resume:");
|
|
388
|
+
expect(r.stdout).toMatch(/pgai joe result \d+/);
|
|
389
|
+
|
|
390
|
+
const match = r.stdout.match(/pgai joe result (\d+)/);
|
|
391
|
+
const commandId = match ? match[1] : "";
|
|
392
|
+
// The id is one the fake minted ABOVE 2^53 — it must be the exact string
|
|
393
|
+
// the run rpc returned (a parseInt round-trip would corrupt it).
|
|
394
|
+
expect(commandId).toMatch(/^900719925474099\d+$/);
|
|
395
|
+
|
|
396
|
+
// `pgai joe result <id>` still returns pending for the cold instance and
|
|
397
|
+
// exits 1 — the output rpc must receive the id VERBATIM as a string.
|
|
398
|
+
const resumed = await runCliAsync(["joe", "result", commandId], env);
|
|
399
|
+
expect(resumed.status).toBe(1);
|
|
400
|
+
expect(resumed.stderr).toContain("result is not ready");
|
|
401
|
+
const outputReq = api.requests.filter((x) => x.pathname.endsWith("/rpc/joe_command_output")).pop();
|
|
402
|
+
expect(outputReq?.body.command_id).toBe(commandId);
|
|
403
|
+
|
|
404
|
+
const jsonRun = await runCliAsync(
|
|
405
|
+
["joe", "plan", "select 1", "--project", "cold-db", "--budget", "0", "--json"],
|
|
406
|
+
env
|
|
407
|
+
);
|
|
408
|
+
expect(jsonRun.status).toBe(0);
|
|
409
|
+
const jsonResume = JSON.parse(jsonRun.stdout) as {
|
|
410
|
+
command_id: string;
|
|
411
|
+
status: string;
|
|
412
|
+
budget_expired: boolean;
|
|
413
|
+
resume: string;
|
|
414
|
+
};
|
|
415
|
+
expect(jsonResume.budget_expired).toBe(true);
|
|
416
|
+
expect(jsonResume.status).toBe("pending");
|
|
417
|
+
expect(jsonResume.resume).toBe(`pgai joe result ${jsonResume.command_id}`);
|
|
418
|
+
} finally {
|
|
419
|
+
api.stop();
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("pgai joe result renders a completed command's full output", async () => {
|
|
424
|
+
const api = startFakeApi();
|
|
425
|
+
const env = isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl });
|
|
426
|
+
try {
|
|
427
|
+
// Complete a command on the warm instance, then re-fetch it by id.
|
|
428
|
+
const run = await runCliAsync(["joe", "plan", "select 1", "--project", "12", "--json"], env);
|
|
429
|
+
expect(run.status).toBe(0);
|
|
430
|
+
const commandId = (JSON.parse(run.stdout) as { command_id: string }).command_id;
|
|
431
|
+
|
|
432
|
+
const resumed = await runCliAsync(["joe", "result", commandId], env);
|
|
433
|
+
expect(resumed.status).toBe(0);
|
|
434
|
+
expect(resumed.stdout).toContain("Seq Scan on users");
|
|
435
|
+
expect(resumed.stdout).toContain("stats:");
|
|
436
|
+
|
|
437
|
+
const resumedJson = await runCliAsync(["joe", "result", commandId, "--json"], env);
|
|
438
|
+
expect(resumedJson.status).toBe(0);
|
|
439
|
+
const jsonResult = JSON.parse(resumedJson.stdout) as { status: string; plan_text: string };
|
|
440
|
+
expect(jsonResult.status).toBe("ok");
|
|
441
|
+
expect(jsonResult.plan_text).toContain("Seq Scan on users");
|
|
442
|
+
} finally {
|
|
443
|
+
api.stop();
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("pgai joe result exits 1 for a terminal error (JSON still printed with --json)", async () => {
|
|
448
|
+
const api = startFakeApi();
|
|
449
|
+
const env = isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl });
|
|
450
|
+
try {
|
|
451
|
+
const human = await runCliAsync(["joe", "result", "424242"], env);
|
|
452
|
+
expect(human.status).toBe(1);
|
|
453
|
+
expect(human.stderr).toContain("ERROR: something failed");
|
|
454
|
+
|
|
455
|
+
const asJson = await runCliAsync(["joe", "result", "424242", "--json"], env);
|
|
456
|
+
expect(asJson.status).toBe(1);
|
|
457
|
+
expect((JSON.parse(asJson.stdout) as { status: string }).status).toBe("error");
|
|
458
|
+
|
|
459
|
+
const pending = await runCliAsync(["joe", "result", "424244", "--json"], env);
|
|
460
|
+
expect(pending.status).toBe(1);
|
|
461
|
+
expect((JSON.parse(pending.stdout) as { status: string }).status).toBe("pending");
|
|
462
|
+
} finally {
|
|
463
|
+
api.stop();
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("a terminal error from the one-shot exits 1 with the error message", async () => {
|
|
468
|
+
// A plan against the role-gated instance surfaces the PT403 detail and exits 1.
|
|
469
|
+
const api = startFakeApi();
|
|
470
|
+
try {
|
|
471
|
+
const r = await runCliAsync(
|
|
472
|
+
["joe", "plan", "select 1", "--project", "no-role"],
|
|
473
|
+
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
474
|
+
);
|
|
475
|
+
expect(r.status).toBe(1);
|
|
476
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("Joe API v2 requires the All Features role");
|
|
477
|
+
} finally {
|
|
478
|
+
api.stop();
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("a command Joe fails (status=error) exits 1 with the error; --json still prints the row", async () => {
|
|
483
|
+
const api = startFakeApi();
|
|
484
|
+
const env = isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl });
|
|
485
|
+
try {
|
|
486
|
+
const human = await runCliAsync(["joe", "plan", "select * from nope", "--project", "err-db"], env);
|
|
487
|
+
expect(human.status).toBe(1);
|
|
488
|
+
expect(human.stderr).toContain('ERROR: relation "nope" does not exist');
|
|
489
|
+
|
|
490
|
+
const asJson = await runCliAsync(["joe", "plan", "select * from nope", "--project", "err-db", "--json"], env);
|
|
491
|
+
expect(asJson.status).toBe(1);
|
|
492
|
+
const row = JSON.parse(asJson.stdout) as { status: string; error: string };
|
|
493
|
+
expect(row.status).toBe("error");
|
|
494
|
+
expect(row.error).toContain("does not exist");
|
|
495
|
+
} finally {
|
|
496
|
+
api.stop();
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test("a 502-ing output endpoint surfaces the resume handle instead of losing the id", async () => {
|
|
501
|
+
// The run rpc succeeded (valid command id); every output poll 502s. The
|
|
502
|
+
// one-shot must NOT throw the id away — it exits 0 with the resume line,
|
|
503
|
+
// and the id is the exact string the run rpc returned.
|
|
504
|
+
const api = startFakeApi();
|
|
505
|
+
try {
|
|
506
|
+
const r = await runCliAsync(
|
|
507
|
+
["joe", "plan", "select 1", "--project", "flaky-db", "--budget", "0.2"],
|
|
508
|
+
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
509
|
+
);
|
|
510
|
+
expect(r.status).toBe(0);
|
|
511
|
+
expect(r.stdout).toContain("resume:");
|
|
512
|
+
const match = r.stdout.match(/pgai joe result (\d+)/);
|
|
513
|
+
expect(match?.[1]).toMatch(/^900719925474099\d+$/);
|
|
514
|
+
// The run rpc really was hit and the polls really did 502.
|
|
515
|
+
expect(api.requests.some((x) => x.pathname.endsWith("/rpc/joe_command_run"))).toBe(true);
|
|
516
|
+
expect(api.requests.some((x) => x.pathname.endsWith("/rpc/joe_command_output"))).toBe(true);
|
|
517
|
+
} finally {
|
|
518
|
+
api.stop();
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
test("a terminal 404 on joe result aborts with a legible message", async () => {
|
|
523
|
+
const api = startFakeApi();
|
|
524
|
+
try {
|
|
525
|
+
const r = await runCliAsync(
|
|
526
|
+
["joe", "result", "111"],
|
|
527
|
+
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
528
|
+
);
|
|
529
|
+
expect(r.status).toBe(1);
|
|
530
|
+
expect(r.stderr).toContain("Failed to fetch command output");
|
|
531
|
+
expect(r.stderr).toContain("404");
|
|
532
|
+
} finally {
|
|
533
|
+
api.stop();
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
test("budget-expiry resume hint reports the ACTUAL --budget, not the hardcoded default", async () => {
|
|
538
|
+
const api = startFakeApi();
|
|
539
|
+
try {
|
|
540
|
+
const r = await runCliAsync(
|
|
541
|
+
["joe", "plan", "select 1", "--project", "cold-db", "--budget", "0"],
|
|
542
|
+
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
543
|
+
);
|
|
544
|
+
expect(r.status).toBe(0);
|
|
545
|
+
expect(r.stdout).toContain("budget 0s reached");
|
|
546
|
+
expect(r.stdout).not.toContain("budget 25s reached");
|
|
547
|
+
} finally {
|
|
548
|
+
api.stop();
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test("negative --budget is rejected before any network call", async () => {
|
|
553
|
+
const api = startFakeApi();
|
|
554
|
+
try {
|
|
555
|
+
const r = await runCliAsync(
|
|
556
|
+
["joe", "plan", "select 1", "--project", "12", "--budget", "-5"],
|
|
557
|
+
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
558
|
+
);
|
|
559
|
+
expect(r.status).toBe(1);
|
|
560
|
+
expect(r.stderr).toContain("--budget must be a non-negative number");
|
|
561
|
+
expect(api.requests.length).toBe(0);
|
|
562
|
+
} finally {
|
|
563
|
+
api.stop();
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test("joe terminate rejects a non-numeric / trailing-garbage pid (no network at all)", async () => {
|
|
568
|
+
// A pid must be a bare positive integer. parseInt() would silently accept
|
|
569
|
+
// "12x"→12, "-5"→-5, "1.5"→1 and terminate a WRONG backend. Those must be
|
|
570
|
+
// a clean, typed rejection that never reaches any rpc.
|
|
571
|
+
const api = startFakeApi();
|
|
572
|
+
try {
|
|
573
|
+
for (const bad of ["0", "12x", "abc", "1.5", "0x10", " "]) {
|
|
574
|
+
const r = await runCliAsync(
|
|
575
|
+
["joe", "terminate", bad, "--project", "12"],
|
|
576
|
+
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
577
|
+
);
|
|
578
|
+
expect(r.status).toBe(1);
|
|
579
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("pid must be a positive integer");
|
|
580
|
+
}
|
|
581
|
+
expect(api.requests.length).toBe(0);
|
|
582
|
+
} finally {
|
|
583
|
+
api.stop();
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test("joe describe rejects a variant outside Joe's psql allowlist", async () => {
|
|
588
|
+
const api = startFakeApi();
|
|
589
|
+
try {
|
|
590
|
+
const r = await runCliAsync(
|
|
591
|
+
["joe", "describe", "users", "--project", "12", "--variant", "\\dx"],
|
|
592
|
+
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
593
|
+
);
|
|
594
|
+
expect(r.status).toBe(1);
|
|
595
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("Unsupported describe variant");
|
|
596
|
+
expect(api.requests.length).toBe(0);
|
|
597
|
+
} finally {
|
|
598
|
+
api.stop();
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
test("a project without a Joe instance fails with a clear message", async () => {
|
|
603
|
+
const api = startFakeApi();
|
|
604
|
+
try {
|
|
605
|
+
const r = await runCliAsync(
|
|
606
|
+
["joe", "plan", "select 1", "--project", "no-joe"],
|
|
607
|
+
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
608
|
+
);
|
|
609
|
+
expect(r.status).toBe(1);
|
|
610
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("has no Joe instance");
|
|
611
|
+
expect(api.requests.some((x) => x.pathname.endsWith("/rpc/joe_command_run"))).toBe(false);
|
|
612
|
+
} finally {
|
|
613
|
+
api.stop();
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
test("neither --project nor --instance-id fails fast with the v1 hint", () => {
|
|
618
|
+
const r = runCli(["joe", "plan", "select 1"], isolatedEnv({ PGAI_API_KEY: "k" }));
|
|
619
|
+
expect(r.status).toBe(1);
|
|
620
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("--instance-id");
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
test("missing API key fails fast", () => {
|
|
624
|
+
const r = runCli(["joe", "plan", "select 1", "--project", "12"], isolatedEnv());
|
|
625
|
+
expect(r.status).toBe(1);
|
|
626
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
|
|
627
|
+
});
|
|
628
|
+
});
|