postgresai 0.16.0-dev.1 → 0.16.0-dev.10
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 +84 -1
- package/bin/postgres-ai.ts +789 -6
- package/bun.lock +4 -4
- package/dist/bin/postgres-ai.js +2621 -279
- package/dist/sql/06.helpers.sql +0 -122
- package/dist/sql/sql/06.helpers.sql +0 -122
- package/lib/dblab.ts +449 -0
- package/lib/init.ts +0 -8
- package/lib/joe.ts +761 -0
- package/lib/mcp-server.ts +625 -0
- package/lib/supabase.ts +0 -18
- package/lib/util.ts +125 -18
- package/package.json +1 -1
- package/sql/06.helpers.sql +0 -122
- package/test/dblab.cli.test.ts +373 -0
- package/test/dblab.test.ts +488 -0
- package/test/e2e/pgai-e2e-smoke.sh +192 -0
- package/test/init.integration.test.ts +9 -79
- package/test/joe.cli.test.ts +469 -0
- package/test/joe.test.ts +858 -0
- package/test/monitoring.test.ts +54 -3
- package/test/util.test.ts +111 -1
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import { describe, test, expect, mock, afterEach, spyOn } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
resolveDblabInstanceId,
|
|
4
|
+
isNumericProjectRef,
|
|
5
|
+
createClone,
|
|
6
|
+
listClones,
|
|
7
|
+
getClone,
|
|
8
|
+
resetClone,
|
|
9
|
+
destroyClone,
|
|
10
|
+
listBranches,
|
|
11
|
+
createBranch,
|
|
12
|
+
deleteBranch,
|
|
13
|
+
branchLog,
|
|
14
|
+
listSnapshots,
|
|
15
|
+
createSnapshot,
|
|
16
|
+
destroySnapshot,
|
|
17
|
+
} from "../lib/dblab";
|
|
18
|
+
import { handleToolCall } from "../lib/mcp-server";
|
|
19
|
+
|
|
20
|
+
const originalFetch = globalThis.fetch;
|
|
21
|
+
const API = "https://api.example.com";
|
|
22
|
+
|
|
23
|
+
interface Captured {
|
|
24
|
+
url: string;
|
|
25
|
+
options: RequestInit;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Stub fetch, returning `body` for every call, capturing the last request. */
|
|
29
|
+
function stubFetch(body: unknown, status = 200): { get: () => Captured | null } {
|
|
30
|
+
let captured: Captured | null = null;
|
|
31
|
+
globalThis.fetch = mock((url: string, options: RequestInit) => {
|
|
32
|
+
captured = { url, options };
|
|
33
|
+
return Promise.resolve(
|
|
34
|
+
new Response(typeof body === "string" ? body : JSON.stringify(body), {
|
|
35
|
+
status,
|
|
36
|
+
headers: { "Content-Type": "application/json" },
|
|
37
|
+
})
|
|
38
|
+
);
|
|
39
|
+
}) as unknown as typeof fetch;
|
|
40
|
+
return { get: () => captured };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Route fetch by URL: `/rpc/projects_list` → projects, `/rpc/dblab_api_call` → reply. */
|
|
44
|
+
function routeFetch(projects: unknown[], reply: unknown): { calls: Captured[] } {
|
|
45
|
+
const calls: Captured[] = [];
|
|
46
|
+
globalThis.fetch = mock((url: string, options: RequestInit) => {
|
|
47
|
+
calls.push({ url, options });
|
|
48
|
+
if (String(url).includes("/rpc/projects_list")) {
|
|
49
|
+
return Promise.resolve(new Response(JSON.stringify(projects), { status: 200 }));
|
|
50
|
+
}
|
|
51
|
+
return Promise.resolve(new Response(JSON.stringify(reply), { status: 200 }));
|
|
52
|
+
}) as unknown as typeof fetch;
|
|
53
|
+
return { calls };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function bodyOf(c: Captured | null): any {
|
|
57
|
+
return JSON.parse((c!.options.body as string) ?? "{}");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
globalThis.fetch = originalFetch;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// resolveDblabInstanceId
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
describe("resolveDblabInstanceId", () => {
|
|
69
|
+
const PROJECTS = [
|
|
70
|
+
{ project_id: 12, alias: "main-db", name: "Main DB", label: "Main DB", joe_ready: true, tunnel: false, instance_id: 1, dblab_instance_id: 7 },
|
|
71
|
+
{ project_id: 34, alias: "analytics", name: "Analytics", label: null, joe_ready: false, tunnel: false, instance_id: null, dblab_instance_id: 9 },
|
|
72
|
+
{ project_id: 56, alias: "no-dblab", name: "No DBLab", label: null, joe_ready: false, tunnel: false, instance_id: null, dblab_instance_id: null },
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
test("throws when apiKey is missing", async () => {
|
|
76
|
+
await expect(
|
|
77
|
+
resolveDblabInstanceId({ apiKey: "", apiBaseUrl: API, project: "12" })
|
|
78
|
+
).rejects.toThrow("API key is required");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("throws when project is missing", async () => {
|
|
82
|
+
await expect(
|
|
83
|
+
resolveDblabInstanceId({ apiKey: "k", apiBaseUrl: API, project: " " })
|
|
84
|
+
).rejects.toThrow("project is required");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("isNumericProjectRef distinguishes id from alias", () => {
|
|
88
|
+
expect(isNumericProjectRef("12")).toBe(true);
|
|
89
|
+
expect(isNumericProjectRef(" 34 ")).toBe(true);
|
|
90
|
+
expect(isNumericProjectRef("main-db")).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("resolves a numeric project id to its dblab instance id (string) via projects_list", async () => {
|
|
94
|
+
const cap = stubFetch(PROJECTS);
|
|
95
|
+
const id = await resolveDblabInstanceId({ apiKey: "k", apiBaseUrl: API, project: "34" });
|
|
96
|
+
expect(id).toBe("9");
|
|
97
|
+
const c = cap.get()!;
|
|
98
|
+
expect(c.url).toBe(`${API}/rpc/projects_list`);
|
|
99
|
+
expect(c.options.method).toBe("POST");
|
|
100
|
+
expect((c.options.headers as Record<string, string>)["access-token"]).toBe("k");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("resolves an alias (case-insensitive) to its dblab instance id", async () => {
|
|
104
|
+
stubFetch(PROJECTS);
|
|
105
|
+
const id = await resolveDblabInstanceId({ apiKey: "k", apiBaseUrl: API, project: "Main-DB" });
|
|
106
|
+
expect(id).toBe("7");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("resolves a project name to its dblab instance id", async () => {
|
|
110
|
+
stubFetch(PROJECTS);
|
|
111
|
+
const id = await resolveDblabInstanceId({ apiKey: "k", apiBaseUrl: API, project: "analytics" });
|
|
112
|
+
expect(id).toBe("9");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("passes org_id in the rpc body when orgId is provided", async () => {
|
|
116
|
+
const cap = stubFetch(PROJECTS);
|
|
117
|
+
await resolveDblabInstanceId({ apiKey: "k", apiBaseUrl: API, project: "12", orgId: 5 });
|
|
118
|
+
expect(bodyOf(cap.get()).org_id).toBe(5);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("throws a helpful error when no project matches", async () => {
|
|
122
|
+
stubFetch(PROJECTS);
|
|
123
|
+
await expect(
|
|
124
|
+
resolveDblabInstanceId({ apiKey: "k", apiBaseUrl: API, project: "nope" })
|
|
125
|
+
).rejects.toThrow(/No DBLab instance found for project 'nope'/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("throws when the project exists but has no active DBLab instance", async () => {
|
|
129
|
+
stubFetch(PROJECTS);
|
|
130
|
+
await expect(
|
|
131
|
+
resolveDblabInstanceId({ apiKey: "k", apiBaseUrl: API, project: "no-dblab" })
|
|
132
|
+
).rejects.toThrow(/has no active DBLab instance/);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("surfaces an HTTP error from the listing", async () => {
|
|
136
|
+
stubFetch('{"message":"boom"}', 500);
|
|
137
|
+
await expect(
|
|
138
|
+
resolveDblabInstanceId({ apiKey: "k", apiBaseUrl: API, project: "12" })
|
|
139
|
+
).rejects.toThrow(/Failed to resolve project's DBLab instance/);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// dblab_api_call proxy — action/method/data per verb
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
describe("clone verbs → dblab_api_call", () => {
|
|
148
|
+
const common = { apiKey: "k", apiBaseUrl: API, instanceId: "7" };
|
|
149
|
+
|
|
150
|
+
test("createClone posts /clone with a minimal data body", async () => {
|
|
151
|
+
const cap = stubFetch({ id: "c-1" });
|
|
152
|
+
await createClone({ ...common });
|
|
153
|
+
const c = cap.get()!;
|
|
154
|
+
expect(c.url).toBe(`${API}/rpc/dblab_api_call`);
|
|
155
|
+
expect(c.options.method).toBe("POST");
|
|
156
|
+
const b = bodyOf(c);
|
|
157
|
+
expect(b.instance_id).toBe("7");
|
|
158
|
+
expect(b.action).toBe("/clone");
|
|
159
|
+
expect(b.method).toBe("post");
|
|
160
|
+
expect(b.data).toEqual({ protected: false });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("createClone maps branch / snapshot / db / protected into data", async () => {
|
|
164
|
+
const cap = stubFetch({ id: "c-1" });
|
|
165
|
+
await createClone({
|
|
166
|
+
...common,
|
|
167
|
+
cloneId: "c-1",
|
|
168
|
+
branch: "feature-idx",
|
|
169
|
+
snapshotId: "s-9",
|
|
170
|
+
dbUser: "u",
|
|
171
|
+
dbPassword: "p",
|
|
172
|
+
isProtected: true,
|
|
173
|
+
});
|
|
174
|
+
expect(bodyOf(cap.get()).data).toEqual({
|
|
175
|
+
protected: true,
|
|
176
|
+
id: "c-1",
|
|
177
|
+
branch: "feature-idx",
|
|
178
|
+
snapshot: { id: "s-9" },
|
|
179
|
+
db: { username: "u", password: "p" },
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("listClones gets /clones with no data", async () => {
|
|
184
|
+
const cap = stubFetch([]);
|
|
185
|
+
await listClones({ ...common });
|
|
186
|
+
const b = bodyOf(cap.get());
|
|
187
|
+
expect(b.action).toBe("/clones");
|
|
188
|
+
expect(b.method).toBe("get");
|
|
189
|
+
expect(b.data).toBeUndefined();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("getClone gets /clone/<id> (url-encoded)", async () => {
|
|
193
|
+
const cap = stubFetch({ id: "c/1" });
|
|
194
|
+
await getClone({ ...common, cloneId: "c/1" });
|
|
195
|
+
const b = bodyOf(cap.get());
|
|
196
|
+
expect(b.action).toBe("/clone/c%2F1");
|
|
197
|
+
expect(b.method).toBe("get");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("resetClone posts /clone/<id>/reset with latest:true when no snapshot", async () => {
|
|
201
|
+
const cap = stubFetch(true);
|
|
202
|
+
await resetClone({ ...common, cloneId: "c-1" });
|
|
203
|
+
const b = bodyOf(cap.get());
|
|
204
|
+
expect(b.action).toBe("/clone/c-1/reset");
|
|
205
|
+
expect(b.method).toBe("post");
|
|
206
|
+
expect(b.data).toEqual({ latest: true });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("resetClone with a snapshot sends snapshotID + latest:false", async () => {
|
|
210
|
+
const cap = stubFetch(true);
|
|
211
|
+
await resetClone({ ...common, cloneId: "c-1", snapshotId: "s-3" });
|
|
212
|
+
expect(bodyOf(cap.get()).data).toEqual({ latest: false, snapshotID: "s-3" });
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("destroyClone deletes /clone/<id>", async () => {
|
|
216
|
+
const cap = stubFetch("");
|
|
217
|
+
await destroyClone({ ...common, cloneId: "c-1" });
|
|
218
|
+
const b = bodyOf(cap.get());
|
|
219
|
+
expect(b.action).toBe("/clone/c-1");
|
|
220
|
+
expect(b.method).toBe("delete");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("destroyClone requires cloneId", async () => {
|
|
224
|
+
await expect(destroyClone({ ...common, cloneId: "" })).rejects.toThrow("cloneId is required");
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("branch verbs → dblab_api_call", () => {
|
|
229
|
+
const common = { apiKey: "k", apiBaseUrl: API, instanceId: "7" };
|
|
230
|
+
|
|
231
|
+
test("listBranches gets /branches", async () => {
|
|
232
|
+
const cap = stubFetch([]);
|
|
233
|
+
await listBranches({ ...common });
|
|
234
|
+
const b = bodyOf(cap.get());
|
|
235
|
+
expect(b.action).toBe("/branches");
|
|
236
|
+
expect(b.method).toBe("get");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("createBranch posts /branch with branchName and optional base/snapshot", async () => {
|
|
240
|
+
const cap = stubFetch({ name: "feature-idx" });
|
|
241
|
+
await createBranch({ ...common, branchName: "feature-idx", baseBranch: "main", snapshotId: "s-1" });
|
|
242
|
+
const b = bodyOf(cap.get());
|
|
243
|
+
expect(b.action).toBe("/branch");
|
|
244
|
+
expect(b.method).toBe("post");
|
|
245
|
+
expect(b.data).toEqual({ branchName: "feature-idx", baseBranch: "main", snapshotID: "s-1" });
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("createBranch omits base/snapshot when not given", async () => {
|
|
249
|
+
const cap = stubFetch({ name: "b" });
|
|
250
|
+
await createBranch({ ...common, branchName: "b" });
|
|
251
|
+
expect(bodyOf(cap.get()).data).toEqual({ branchName: "b" });
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("deleteBranch deletes /branch/<name> (encoded)", async () => {
|
|
255
|
+
const cap = stubFetch(true);
|
|
256
|
+
await deleteBranch({ ...common, branchName: "feature/x" });
|
|
257
|
+
const b = bodyOf(cap.get());
|
|
258
|
+
expect(b.action).toBe("/branch/feature%2Fx");
|
|
259
|
+
expect(b.method).toBe("delete");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("branchLog gets /branch/<name>/log", async () => {
|
|
263
|
+
const cap = stubFetch([]);
|
|
264
|
+
await branchLog({ ...common, branchName: "feature-idx" });
|
|
265
|
+
const b = bodyOf(cap.get());
|
|
266
|
+
expect(b.action).toBe("/branch/feature-idx/log");
|
|
267
|
+
expect(b.method).toBe("get");
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe("snapshot verbs → dblab_api_call", () => {
|
|
272
|
+
const common = { apiKey: "k", apiBaseUrl: API, instanceId: "7" };
|
|
273
|
+
|
|
274
|
+
test("listSnapshots gets /snapshots (no query when unfiltered)", async () => {
|
|
275
|
+
const cap = stubFetch([]);
|
|
276
|
+
await listSnapshots({ ...common });
|
|
277
|
+
const b = bodyOf(cap.get());
|
|
278
|
+
expect(b.action).toBe("/snapshots");
|
|
279
|
+
expect(b.method).toBe("get");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("listSnapshots appends branch and dataset query params", async () => {
|
|
283
|
+
const cap = stubFetch([]);
|
|
284
|
+
await listSnapshots({ ...common, branchName: "main", dataset: "ds1" });
|
|
285
|
+
expect(bodyOf(cap.get()).action).toBe("/snapshots?branch=main&dataset=ds1");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("createSnapshot posts /branch/snapshot with cloneID + message", async () => {
|
|
289
|
+
const cap = stubFetch({ snapshotID: "s-1" });
|
|
290
|
+
await createSnapshot({ ...common, cloneId: "c-1", message: "before idx" });
|
|
291
|
+
const b = bodyOf(cap.get());
|
|
292
|
+
expect(b.action).toBe("/branch/snapshot");
|
|
293
|
+
expect(b.method).toBe("post");
|
|
294
|
+
expect(b.data).toEqual({ cloneID: "c-1", message: "before idx" });
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("destroySnapshot deletes /snapshot/<id>?force=<bool>", async () => {
|
|
298
|
+
const cap = stubFetch(true);
|
|
299
|
+
await destroySnapshot({ ...common, snapshotId: "s-1", force: true });
|
|
300
|
+
const b = bodyOf(cap.get());
|
|
301
|
+
expect(b.action).toBe("/snapshot/s-1?force=true");
|
|
302
|
+
expect(b.method).toBe("delete");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("destroySnapshot passes a multi-segment zfs snapshot id RAW (no percent-encoding)", async () => {
|
|
306
|
+
const cap = stubFetch(true);
|
|
307
|
+
await destroySnapshot({ ...common, snapshotId: "dblab_pool/branch/main/c-1/r0@20260707065703" });
|
|
308
|
+
// The engine 400s on %2F/%40 — the Console passes the id raw and so must we.
|
|
309
|
+
expect(bodyOf(cap.get()).action).toBe("/snapshot/dblab_pool/branch/main/c-1/r0@20260707065703?force=false");
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("destroySnapshot defaults force=false", async () => {
|
|
313
|
+
const cap = stubFetch(true);
|
|
314
|
+
await destroySnapshot({ ...common, snapshotId: "s-1" });
|
|
315
|
+
expect(bodyOf(cap.get()).action).toBe("/snapshot/s-1?force=false");
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// error / scope-gating paths
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
describe("proxy error paths", () => {
|
|
324
|
+
const common = { apiKey: "k", apiBaseUrl: API, instanceId: "7" };
|
|
325
|
+
|
|
326
|
+
test("a PT403 (scope) surfaces as a formatted 403 error", async () => {
|
|
327
|
+
stubFetch('{"message":"insufficient scope: joe:admin required"}', 403);
|
|
328
|
+
await expect(destroyClone({ ...common, cloneId: "c-1" })).rejects.toThrow(/Failed to destroy clone/);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("a 500 surfaces the operation label", async () => {
|
|
332
|
+
stubFetch('{"message":"boom"}', 500);
|
|
333
|
+
await expect(listClones({ ...common })).rejects.toThrow(/Failed to list clones/);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("proxy requires an instanceId", async () => {
|
|
337
|
+
await expect(listClones({ ...common, instanceId: "" })).rejects.toThrow("instanceId is required");
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// --debug credential redaction
|
|
343
|
+
//
|
|
344
|
+
// `--debug` writes the raw request/response bodies to stderr — and in the MCP
|
|
345
|
+
// server the debug flag is CALLER-controlled (`args.debug`), so an agent can
|
|
346
|
+
// turn it on. Credential fields must be masked the same way the access-token
|
|
347
|
+
// header already is: the clone-create request embeds `data.db.password`
|
|
348
|
+
// (--db-password), and clone create/status replies carry the live clone's
|
|
349
|
+
// `db.password` / `db.connStr`.
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
describe("--debug credential redaction", () => {
|
|
353
|
+
function captureStderr() {
|
|
354
|
+
const spy = spyOn(console, "error").mockImplementation(() => {});
|
|
355
|
+
return {
|
|
356
|
+
logged: () => spy.mock.calls.map((c) => c.map(String).join(" ")).join("\n"),
|
|
357
|
+
restore: () => spy.mockRestore(),
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
test("clone create --debug does not log the --db-password (request body redacted)", async () => {
|
|
362
|
+
const cap = captureStderr();
|
|
363
|
+
try {
|
|
364
|
+
stubFetch({ id: "c1" });
|
|
365
|
+
await createClone({
|
|
366
|
+
apiKey: "k-0123456789abcdef",
|
|
367
|
+
apiBaseUrl: API,
|
|
368
|
+
instanceId: "7",
|
|
369
|
+
cloneId: "c1",
|
|
370
|
+
dbUser: "clone_user",
|
|
371
|
+
dbPassword: "hunter2-cleartext",
|
|
372
|
+
debug: true,
|
|
373
|
+
});
|
|
374
|
+
const logged = cap.logged();
|
|
375
|
+
expect(logged).toContain("Debug: Request body");
|
|
376
|
+
expect(logged).not.toContain("hunter2-cleartext");
|
|
377
|
+
// The rest of the body still logs (redaction, not suppression).
|
|
378
|
+
expect(logged).toContain("clone_user");
|
|
379
|
+
} finally {
|
|
380
|
+
cap.restore();
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("clone status --debug does not log the clone's credentials from the response (password/connStr)", async () => {
|
|
385
|
+
const cap = captureStderr();
|
|
386
|
+
try {
|
|
387
|
+
stubFetch({
|
|
388
|
+
id: "c1",
|
|
389
|
+
status: { code: "OK" },
|
|
390
|
+
db: {
|
|
391
|
+
connStr: "host=dblab port=6002 user=joe password=resp-secret-xyz",
|
|
392
|
+
password: "resp-secret-xyz",
|
|
393
|
+
username: "joe",
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
await getClone({ apiKey: "k-0123456789abcdef", apiBaseUrl: API, instanceId: "7", cloneId: "c1", debug: true });
|
|
397
|
+
const logged = cap.logged();
|
|
398
|
+
expect(logged).toContain("Debug: Response body");
|
|
399
|
+
expect(logged).not.toContain("resp-secret-xyz");
|
|
400
|
+
expect(logged).toContain("username");
|
|
401
|
+
} finally {
|
|
402
|
+
cap.restore();
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// resolve → proxy end-to-end (project → instance → call)
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
describe("project → instance → dblab_api_call", () => {
|
|
412
|
+
test("a verb driven off a resolved instance sends both requests", async () => {
|
|
413
|
+
const projects = [{ project_id: 12, alias: "main-db", dblab_instance_id: 7 }];
|
|
414
|
+
const { calls } = routeFetch(projects, { id: "c-1", status: "ready" });
|
|
415
|
+
const instanceId = await resolveDblabInstanceId({ apiKey: "k", apiBaseUrl: API, project: "main-db" });
|
|
416
|
+
await createClone({ apiKey: "k", apiBaseUrl: API, instanceId });
|
|
417
|
+
expect(calls.length).toBe(2);
|
|
418
|
+
expect(calls[0].url).toContain("/rpc/projects_list");
|
|
419
|
+
expect(calls[1].url).toBe(`${API}/rpc/dblab_api_call`);
|
|
420
|
+
expect(bodyOf(calls[1]).instance_id).toBe("7");
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
// MCP tool parity — each tool resolves the instance then proxies dblab_api_call
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
describe("MCP DBLab tools (handleToolCall)", () => {
|
|
429
|
+
const rootOpts = { apiKey: "k", apiBaseUrl: API };
|
|
430
|
+
|
|
431
|
+
test("clone_create resolves project then proxies /clone POST", async () => {
|
|
432
|
+
const projects = [{ project_id: 12, alias: "main-db", dblab_instance_id: 7 }];
|
|
433
|
+
const { calls } = routeFetch(projects, { ok: true });
|
|
434
|
+
const res = await handleToolCall(
|
|
435
|
+
{ params: { name: "clone_create", arguments: { project: "main-db" } } },
|
|
436
|
+
rootOpts
|
|
437
|
+
);
|
|
438
|
+
expect(res.isError).toBeFalsy();
|
|
439
|
+
expect(calls[0].url).toContain("/rpc/projects_list");
|
|
440
|
+
const b = bodyOf(calls[1]);
|
|
441
|
+
expect(b.instance_id).toBe("7");
|
|
442
|
+
expect(b.action).toBe("/clone");
|
|
443
|
+
expect(b.method).toBe("post");
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("clone_destroy proxies /clone/<id> DELETE", async () => {
|
|
447
|
+
const projects = [{ project_id: 12, alias: "main-db", dblab_instance_id: 7 }];
|
|
448
|
+
const { calls } = routeFetch(projects, { ok: true });
|
|
449
|
+
const res = await handleToolCall(
|
|
450
|
+
{ params: { name: "clone_destroy", arguments: { project: "main-db", clone_id: "c-1" } } },
|
|
451
|
+
rootOpts
|
|
452
|
+
);
|
|
453
|
+
expect(res.isError).toBeFalsy();
|
|
454
|
+
const b = bodyOf(calls[1]);
|
|
455
|
+
expect(b.action).toBe("/clone/c-1");
|
|
456
|
+
expect(b.method).toBe("delete");
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test("snapshot_create proxies /branch/snapshot POST with cloneID", async () => {
|
|
460
|
+
const projects = [{ project_id: 12, alias: "main-db", dblab_instance_id: 7 }];
|
|
461
|
+
const { calls } = routeFetch(projects, { ok: true });
|
|
462
|
+
await handleToolCall(
|
|
463
|
+
{ params: { name: "snapshot_create", arguments: { project: "main-db", clone_id: "c-1", message: "m" } } },
|
|
464
|
+
rootOpts
|
|
465
|
+
);
|
|
466
|
+
const b = bodyOf(calls[1]);
|
|
467
|
+
expect(b.action).toBe("/branch/snapshot");
|
|
468
|
+
expect(b.data).toEqual({ cloneID: "c-1", message: "m" });
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("a DBLab tool without project returns an isError response", async () => {
|
|
472
|
+
const res = await handleToolCall(
|
|
473
|
+
{ params: { name: "clone_list", arguments: {} } },
|
|
474
|
+
rootOpts
|
|
475
|
+
);
|
|
476
|
+
expect(res.isError).toBe(true);
|
|
477
|
+
expect(res.content[0].text).toContain("project is required");
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("clone_status requires clone_id", async () => {
|
|
481
|
+
const res = await handleToolCall(
|
|
482
|
+
{ params: { name: "clone_status", arguments: { project: "main-db" } } },
|
|
483
|
+
rootOpts
|
|
484
|
+
);
|
|
485
|
+
expect(res.isError).toBe(true);
|
|
486
|
+
expect(res.content[0].text).toContain("clone_id is required");
|
|
487
|
+
});
|
|
488
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# pgai-e2e-smoke.sh — end-to-end smoke test for the `pgai` (Joe API v2) CLI.
|
|
4
|
+
#
|
|
5
|
+
# Exercises the FULL grouped CLI surface against a live platform + DBLab + Joe
|
|
6
|
+
# stack and asserts expected outputs / exit codes:
|
|
7
|
+
# * projects
|
|
8
|
+
# * every `pgai joe` verb (plan, explain, exec, hypo, activity, describe,
|
|
9
|
+
# terminate, reset, history, status, result)
|
|
10
|
+
# * every `pgai dblab` verb (clone create/list/status/reset/destroy,
|
|
11
|
+
# branch list/create/log/delete, snapshot list/create/destroy)
|
|
12
|
+
# * an MCP tools/list handshake over stdio
|
|
13
|
+
#
|
|
14
|
+
# It is IDEMPOTENT and SAFE TO RE-RUN: every clone/branch/snapshot it creates
|
|
15
|
+
# gets a unique, run-scoped name and is destroyed on exit (even on failure), and
|
|
16
|
+
# it uses a throwaway XDG_CONFIG_HOME so it never touches real user state.
|
|
17
|
+
#
|
|
18
|
+
# Required env:
|
|
19
|
+
# PGAI_API_BASE_URL platform PostgREST base URL (e.g. http://host:9508)
|
|
20
|
+
# PGAI_API_KEY opaque org API token (access-token header)
|
|
21
|
+
# Optional env:
|
|
22
|
+
# PGAI_BIN CLI invocation (default: "pgai"; e.g. "npx --yes pgai@dev"
|
|
23
|
+
# or "node /path/cli/dist/bin/postgres-ai.js")
|
|
24
|
+
# PGAI_PROJECT project id or alias (default: first joe_ready project)
|
|
25
|
+
# PGAI_TABLE an existing table for describe/hypo (default: pgbench_accounts)
|
|
26
|
+
# PGAI_HYPO_COL an indexable column on PGAI_TABLE (default: bid)
|
|
27
|
+
# SMOKE_SKIP_DBLAB set to 1 to skip the DBLab verbs (Joe-only smoke)
|
|
28
|
+
#
|
|
29
|
+
# Exit code: 0 = all assertions passed, 1 = one or more failed / setup error.
|
|
30
|
+
|
|
31
|
+
set -u
|
|
32
|
+
: "${PGAI_API_BASE_URL:?set PGAI_API_BASE_URL}"
|
|
33
|
+
: "${PGAI_API_KEY:?set PGAI_API_KEY}"
|
|
34
|
+
PGAI_BIN="${PGAI_BIN:-pgai}"
|
|
35
|
+
PGAI_TABLE="${PGAI_TABLE:-pgbench_accounts}"
|
|
36
|
+
PGAI_HYPO_COL="${PGAI_HYPO_COL:-bid}"
|
|
37
|
+
RUN="smoke$$-$(date +%s)"
|
|
38
|
+
WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/pgai-smoke-XXXXXX")"
|
|
39
|
+
export XDG_CONFIG_HOME="$WORKDIR/cfg"; mkdir -p "$XDG_CONFIG_HOME"
|
|
40
|
+
PASS=0; FAIL=0
|
|
41
|
+
CREATED_CLONES=(); CREATED_BRANCHES=(); CREATED_SNAPSHOTS=()
|
|
42
|
+
|
|
43
|
+
# The CLI reads PGAI_API_BASE_URL / PGAI_API_KEY from the environment; export them
|
|
44
|
+
# so subshells (bash -c, timeout, the MCP pipe) inherit the credentials.
|
|
45
|
+
export PGAI_API_BASE_URL PGAI_API_KEY
|
|
46
|
+
|
|
47
|
+
# Resolve a bare command name to an absolute path so the on-PATH wrapper below
|
|
48
|
+
# does not recurse into itself.
|
|
49
|
+
case "$PGAI_BIN" in
|
|
50
|
+
*" "*) : ;; # multi-word (e.g. "node /path/postgres-ai.js") — leave as-is
|
|
51
|
+
*) PGAI_BIN="$(command -v "$PGAI_BIN" 2>/dev/null || echo "$PGAI_BIN")" ;;
|
|
52
|
+
esac
|
|
53
|
+
|
|
54
|
+
# Expose a real `pgai` executable on PATH (not a shell function) so every context
|
|
55
|
+
# — direct calls, `bash -c "pgai …"`, and `timeout … pgai …` — resolves it.
|
|
56
|
+
mkdir -p "$WORKDIR/bin"
|
|
57
|
+
cat > "$WORKDIR/bin/pgai" <<EOF
|
|
58
|
+
#!/usr/bin/env bash
|
|
59
|
+
exec $PGAI_BIN "\$@"
|
|
60
|
+
EOF
|
|
61
|
+
chmod +x "$WORKDIR/bin/pgai"
|
|
62
|
+
export PATH="$WORKDIR/bin:$PATH"
|
|
63
|
+
|
|
64
|
+
# check <label> <expected_exit> <grep-pattern-or-.> -- <cmd...>
|
|
65
|
+
check() {
|
|
66
|
+
local label="$1" want_exit="$2" pat="$3"; shift 3; [ "$1" = "--" ] && shift
|
|
67
|
+
local out rc
|
|
68
|
+
out="$("$@" 2>&1)"; rc=$?
|
|
69
|
+
local ok=1
|
|
70
|
+
[ "$rc" = "$want_exit" ] || ok=0
|
|
71
|
+
if [ "$pat" != "." ]; then printf '%s' "$out" | grep -qiE "$pat" || ok=0; fi
|
|
72
|
+
if [ "$ok" = 1 ]; then
|
|
73
|
+
PASS=$((PASS+1)); printf ' PASS %-42s (exit %s)\n' "$label" "$rc"
|
|
74
|
+
else
|
|
75
|
+
FAIL=$((FAIL+1)); printf ' FAIL %-42s (exit %s, want %s; pattern /%s/)\n' "$label" "$rc" "$want_exit" "$pat"
|
|
76
|
+
printf ' output: %s\n' "$(printf '%s' "$out" | head -3 | tr '\n' '|')"
|
|
77
|
+
fi
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
cleanup() {
|
|
81
|
+
local c b s
|
|
82
|
+
for c in "${CREATED_CLONES[@]:-}"; do [ -n "$c" ] && pgai dblab clone destroy "$c" --project "$PROJECT" >/dev/null 2>&1; done
|
|
83
|
+
for s in "${CREATED_SNAPSHOTS[@]:-}"; do [ -n "$s" ] && pgai dblab snapshot destroy "$s" --project "$PROJECT" --force >/dev/null 2>&1; done
|
|
84
|
+
for b in "${CREATED_BRANCHES[@]:-}"; do [ -n "$b" ] && pgai dblab branch delete "$b" --project "$PROJECT" >/dev/null 2>&1; done
|
|
85
|
+
rm -rf "$WORKDIR"
|
|
86
|
+
}
|
|
87
|
+
trap cleanup EXIT
|
|
88
|
+
|
|
89
|
+
echo "== pgai E2E smoke =="
|
|
90
|
+
echo " bin=$PGAI_BIN base=$PGAI_API_BASE_URL run=$RUN"
|
|
91
|
+
echo " version: $(pgai --version 2>&1 | tail -1)"
|
|
92
|
+
|
|
93
|
+
# ---- discover a target project ---------------------------------------------
|
|
94
|
+
PROJECT="${PGAI_PROJECT:-}"
|
|
95
|
+
if [ -z "$PROJECT" ]; then
|
|
96
|
+
PROJECT="$(pgai projects --json 2>/dev/null | jq -r 'map(select(.joe_ready==true))[0].project_id // empty')"
|
|
97
|
+
fi
|
|
98
|
+
if [ -z "$PROJECT" ]; then echo "FATAL: no joe_ready project found (set PGAI_PROJECT)"; exit 1; fi
|
|
99
|
+
echo " target project: $PROJECT table: $PGAI_TABLE"
|
|
100
|
+
|
|
101
|
+
echo; echo "-- org / projects --"
|
|
102
|
+
check "projects (table)" 0 "PROJECT_ID" -- pgai projects
|
|
103
|
+
check "projects (--json array)" 0 "." -- bash -c 'pgai projects --json | jq -e "type==\"array\"" >/dev/null'
|
|
104
|
+
|
|
105
|
+
echo; echo "-- joe verbs --"
|
|
106
|
+
check "joe plan" 0 "cost=" -- pgai joe plan "select 1" --project "$PROJECT"
|
|
107
|
+
check "joe plan --json" 0 "." -- bash -c "pgai joe plan 'select 1' --project '$PROJECT' --json | jq -e '.plan_json' >/dev/null"
|
|
108
|
+
check "joe explain" 0 "cost=" -- pgai joe explain "select 1" --project "$PROJECT"
|
|
109
|
+
check "joe exec" 0 "\"answer\": *42|answer" -- pgai joe exec "select 42 as answer" --project "$PROJECT" --json
|
|
110
|
+
check "joe exec empty" 0 "0 rows" -- pgai joe exec "select 1 where false" --project "$PROJECT"
|
|
111
|
+
check "joe hypo" 0 "hypothetical index" -- pgai joe hypo "create index on $PGAI_TABLE ($PGAI_HYPO_COL)" --query "select * from $PGAI_TABLE where $PGAI_HYPO_COL = 1" --project "$PROJECT"
|
|
112
|
+
check "joe activity" 0 "." -- pgai joe activity --project "$PROJECT"
|
|
113
|
+
check "joe describe" 0 "$PGAI_TABLE" -- pgai joe describe "$PGAI_TABLE" --project "$PROJECT"
|
|
114
|
+
check "joe terminate (no such pid)" 0 "terminated no|pid" -- pgai joe terminate 999999 --project "$PROJECT"
|
|
115
|
+
check "joe terminate (bad pid rejected)" 1 "pid must be a number" -- pgai joe terminate 12x --project "$PROJECT"
|
|
116
|
+
check "joe history (M1b stub)" 1 "not available yet" -- pgai joe history "anything" --project "$PROJECT"
|
|
117
|
+
|
|
118
|
+
# status/result on a real submitted command
|
|
119
|
+
CID="$(pgai joe plan "select 2" --project "$PROJECT" --json 2>/dev/null | jq -r '.command_id // empty')"
|
|
120
|
+
if [ -n "$CID" ]; then
|
|
121
|
+
check "joe status <id>" 0 "$CID" -- pgai joe status "$CID"
|
|
122
|
+
check "joe result <id>" 0 "cost=|done" -- pgai joe result "$CID"
|
|
123
|
+
else
|
|
124
|
+
FAIL=$((FAIL+1)); echo " FAIL joe status/result (no command_id captured)"
|
|
125
|
+
fi
|
|
126
|
+
|
|
127
|
+
# reset re-snapshots the session clone (restores a pristine state)
|
|
128
|
+
check "joe reset" 0 "reset ok" -- pgai joe reset --project "$PROJECT"
|
|
129
|
+
|
|
130
|
+
echo; echo "-- error surfacing / ergonomics --"
|
|
131
|
+
check "wrong base url (ECONNREFUSED)" 1 "could not reach" -- pgai --api-base-url http://127.0.0.1:59999 projects
|
|
132
|
+
check "exec syntax error (clean)" 1 "error|Query error" -- pgai joe exec "selct 1" --project "$PROJECT"
|
|
133
|
+
check "plan unparsable (400)" 1 "parse|400" -- pgai joe plan "not sql ;;;" --project "$PROJECT"
|
|
134
|
+
|
|
135
|
+
if [ "${SMOKE_SKIP_DBLAB:-0}" != "1" ]; then
|
|
136
|
+
echo; echo "-- dblab verbs --"
|
|
137
|
+
check "dblab snapshot list" 0 "." -- bash -c "pgai dblab snapshot list --project '$PROJECT' --json | jq -e 'type==\"array\"' >/dev/null"
|
|
138
|
+
check "dblab branch list" 0 "main" -- pgai dblab branch list --project "$PROJECT"
|
|
139
|
+
|
|
140
|
+
CLONE="smoke-clone-$RUN"; PW='Zx9#mQ2!vLp7wRt3'
|
|
141
|
+
check "dblab clone create" 0 "$CLONE|CREATING|OK" -- pgai dblab clone create --project "$PROJECT" --id "$CLONE" --db-user smoke_u --db-password "$PW"
|
|
142
|
+
CREATED_CLONES+=("$CLONE")
|
|
143
|
+
# wait for the clone to reach OK (create is async)
|
|
144
|
+
for _ in $(seq 1 30); do
|
|
145
|
+
st="$(pgai dblab clone status "$CLONE" --project "$PROJECT" --json 2>/dev/null | jq -r '.status.code // .status // empty')"
|
|
146
|
+
[ "$st" = "OK" ] && break; sleep 2
|
|
147
|
+
done
|
|
148
|
+
check "dblab clone status (OK)" 0 "OK" -- pgai dblab clone status "$CLONE" --project "$PROJECT"
|
|
149
|
+
check "dblab clone list (has it)" 0 "$CLONE" -- pgai dblab clone list --project "$PROJECT"
|
|
150
|
+
|
|
151
|
+
SNAP="$(pgai dblab snapshot create --project "$PROJECT" --clone "$CLONE" --message "smoke $RUN" --json 2>/dev/null | jq -r '.snapshotID // .id // empty')"
|
|
152
|
+
if [ -n "$SNAP" ]; then
|
|
153
|
+
CREATED_SNAPSHOTS+=("$SNAP"); PASS=$((PASS+1)); echo " PASS dblab snapshot create ($SNAP)"
|
|
154
|
+
check "dblab snapshot destroy" 0 "destroyed|true|." -- pgai dblab snapshot destroy "$SNAP" --project "$PROJECT" --force
|
|
155
|
+
CREATED_SNAPSHOTS=() # destroyed
|
|
156
|
+
else
|
|
157
|
+
echo " SKIP dblab snapshot create/destroy (engine declined; non-fatal)"
|
|
158
|
+
fi
|
|
159
|
+
|
|
160
|
+
check "dblab clone reset" 0 "." -- pgai dblab clone reset "$CLONE" --project "$PROJECT" --latest
|
|
161
|
+
check "dblab clone destroy" 0 "destroyed|true" -- pgai dblab clone destroy "$CLONE" --project "$PROJECT"
|
|
162
|
+
CREATED_CLONES=() # destroyed
|
|
163
|
+
|
|
164
|
+
BR="smoke-branch-$RUN"
|
|
165
|
+
if pgai dblab branch create "$BR" --project "$PROJECT" --base-branch main >/dev/null 2>&1; then
|
|
166
|
+
CREATED_BRANCHES+=("$BR"); PASS=$((PASS+1)); echo " PASS dblab branch create ($BR)"
|
|
167
|
+
check "dblab branch log" 0 "." -- pgai dblab branch log "$BR" --project "$PROJECT"
|
|
168
|
+
check "dblab branch delete" 0 "deleted|true|." -- pgai dblab branch delete "$BR" --project "$PROJECT"
|
|
169
|
+
CREATED_BRANCHES=() # deleted
|
|
170
|
+
else
|
|
171
|
+
echo " SKIP dblab branch create/log/delete (engine declined; non-fatal)"
|
|
172
|
+
fi
|
|
173
|
+
fi
|
|
174
|
+
|
|
175
|
+
echo; echo "-- MCP --"
|
|
176
|
+
MCP_OUT="$WORKDIR/mcp.txt"
|
|
177
|
+
printf '%s\n' \
|
|
178
|
+
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke","version":"1"}}}' \
|
|
179
|
+
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
|
|
180
|
+
'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
|
|
181
|
+
| timeout 30 pgai mcp start >"$MCP_OUT" 2>/dev/null
|
|
182
|
+
N="$(grep '"id":2' "$MCP_OUT" | jq -r '.result.tools | length' 2>/dev/null)"
|
|
183
|
+
HAS="$(grep '"id":2' "$MCP_OUT" | jq -r '[.result.tools[].name] | (index("plan_query")!=null and index("clone_create")!=null and index("list_projects")!=null)' 2>/dev/null)"
|
|
184
|
+
if [ "${N:-0}" -ge 20 ] && [ "$HAS" = "true" ]; then
|
|
185
|
+
PASS=$((PASS+1)); echo " PASS mcp tools/list ($N tools; joe+dblab+projects present)"
|
|
186
|
+
else
|
|
187
|
+
FAIL=$((FAIL+1)); echo " FAIL mcp tools/list (count=$N has_core=$HAS)"
|
|
188
|
+
fi
|
|
189
|
+
|
|
190
|
+
echo
|
|
191
|
+
echo "== RESULT: $PASS passed, $FAIL failed =="
|
|
192
|
+
[ "$FAIL" = 0 ] && exit 0 || exit 1
|