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
package/sql/06.helpers.sql
CHANGED
|
@@ -2,126 +2,6 @@
|
|
|
2
2
|
-- These functions use SECURITY DEFINER to allow the monitoring user to perform
|
|
3
3
|
-- operations they don't have direct permissions for.
|
|
4
4
|
|
|
5
|
-
/*
|
|
6
|
-
* explain_generic
|
|
7
|
-
*
|
|
8
|
-
* Function to get generic explain plans with optional HypoPG index testing.
|
|
9
|
-
* Requires: PostgreSQL 16+ (for generic_plan option), HypoPG extension (optional).
|
|
10
|
-
*
|
|
11
|
-
* Security notes:
|
|
12
|
-
* - EXPLAIN without ANALYZE is read-only (plans but doesn't execute the query)
|
|
13
|
-
* - PostgreSQL's EXPLAIN only accepts a single statement (primary protection)
|
|
14
|
-
* - Input validation uses a simple heuristic to detect multiple statements
|
|
15
|
-
* (Note: may reject valid queries containing semicolons in string literals)
|
|
16
|
-
*
|
|
17
|
-
* Usage examples:
|
|
18
|
-
* -- Basic generic plan
|
|
19
|
-
* select postgres_ai.explain_generic('select * from users where id = $1');
|
|
20
|
-
*
|
|
21
|
-
* -- JSON format
|
|
22
|
-
* select postgres_ai.explain_generic('select * from users where id = $1', 'json');
|
|
23
|
-
*
|
|
24
|
-
* -- Test a hypothetical index
|
|
25
|
-
* select postgres_ai.explain_generic(
|
|
26
|
-
* 'select * from users where email = $1',
|
|
27
|
-
* 'text',
|
|
28
|
-
* 'create index on users (email)'
|
|
29
|
-
* );
|
|
30
|
-
*/
|
|
31
|
-
create or replace function postgres_ai.explain_generic(
|
|
32
|
-
in query text,
|
|
33
|
-
in format text default 'text',
|
|
34
|
-
in hypopg_index text default null,
|
|
35
|
-
out result text
|
|
36
|
-
)
|
|
37
|
-
language plpgsql
|
|
38
|
-
security definer
|
|
39
|
-
set search_path = pg_catalog, public
|
|
40
|
-
as $$
|
|
41
|
-
declare
|
|
42
|
-
v_line record;
|
|
43
|
-
v_lines text[] := '{}';
|
|
44
|
-
v_explain_query text;
|
|
45
|
-
v_hypo_result record;
|
|
46
|
-
v_version int;
|
|
47
|
-
v_hypopg_available boolean;
|
|
48
|
-
v_clean_query text;
|
|
49
|
-
begin
|
|
50
|
-
-- Check PostgreSQL version (generic_plan requires 16+)
|
|
51
|
-
select current_setting('server_version_num')::int into v_version;
|
|
52
|
-
|
|
53
|
-
if v_version < 160000 then
|
|
54
|
-
raise exception 'generic_plan requires PostgreSQL 16+, current version: %',
|
|
55
|
-
current_setting('server_version');
|
|
56
|
-
end if;
|
|
57
|
-
|
|
58
|
-
-- Input validation: reject empty queries
|
|
59
|
-
if query is null or trim(query) = '' then
|
|
60
|
-
raise exception 'query cannot be empty';
|
|
61
|
-
end if;
|
|
62
|
-
|
|
63
|
-
-- Input validation: detect multiple statements (defense-in-depth)
|
|
64
|
-
-- Note: This is a simple heuristic - EXPLAIN itself only accepts single statements
|
|
65
|
-
-- Limitation: Queries with semicolons inside string literals will be rejected
|
|
66
|
-
v_clean_query := trim(query);
|
|
67
|
-
if v_clean_query like '%;%' then
|
|
68
|
-
-- Strip trailing semicolon if present (common user convenience)
|
|
69
|
-
v_clean_query := regexp_replace(v_clean_query, ';\s*$', '');
|
|
70
|
-
-- If there's still a semicolon, reject (likely multiple statements or semicolon in string)
|
|
71
|
-
if v_clean_query like '%;%' then
|
|
72
|
-
raise exception 'query contains semicolon (multiple statements not allowed; note: semicolons in string literals are also not supported)';
|
|
73
|
-
end if;
|
|
74
|
-
end if;
|
|
75
|
-
|
|
76
|
-
-- Check if HypoPG extension is available
|
|
77
|
-
if hypopg_index is not null then
|
|
78
|
-
select exists(
|
|
79
|
-
select 1 from pg_extension where extname = 'hypopg'
|
|
80
|
-
) into v_hypopg_available;
|
|
81
|
-
|
|
82
|
-
if not v_hypopg_available then
|
|
83
|
-
raise exception 'HypoPG extension is required for hypothetical index testing but is not installed';
|
|
84
|
-
end if;
|
|
85
|
-
|
|
86
|
-
-- Create hypothetical index
|
|
87
|
-
select * into v_hypo_result from hypopg_create_index(hypopg_index);
|
|
88
|
-
raise notice 'Created hypothetical index: % (oid: %)',
|
|
89
|
-
v_hypo_result.indexname, v_hypo_result.indexrelid;
|
|
90
|
-
end if;
|
|
91
|
-
|
|
92
|
-
-- Build and execute EXPLAIN query
|
|
93
|
-
-- Note: EXPLAIN is read-only (plans but doesn't execute), making this safe
|
|
94
|
-
begin
|
|
95
|
-
if lower(format) = 'json' then
|
|
96
|
-
execute 'explain (verbose, settings, generic_plan, format json) ' || v_clean_query
|
|
97
|
-
into result;
|
|
98
|
-
else
|
|
99
|
-
for v_line in execute 'explain (verbose, settings, generic_plan) ' || v_clean_query loop
|
|
100
|
-
v_lines := array_append(v_lines, v_line."QUERY PLAN");
|
|
101
|
-
end loop;
|
|
102
|
-
result := array_to_string(v_lines, e'\n');
|
|
103
|
-
end if;
|
|
104
|
-
exception when others then
|
|
105
|
-
-- Clean up hypothetical index before re-raising
|
|
106
|
-
if hypopg_index is not null then
|
|
107
|
-
perform hypopg_reset();
|
|
108
|
-
end if;
|
|
109
|
-
raise;
|
|
110
|
-
end;
|
|
111
|
-
|
|
112
|
-
-- Clean up hypothetical index
|
|
113
|
-
if hypopg_index is not null then
|
|
114
|
-
perform hypopg_reset();
|
|
115
|
-
end if;
|
|
116
|
-
end;
|
|
117
|
-
$$;
|
|
118
|
-
|
|
119
|
-
comment on function postgres_ai.explain_generic(text, text, text) is
|
|
120
|
-
'Returns generic EXPLAIN plan with optional HypoPG index testing (requires PG16+)';
|
|
121
|
-
|
|
122
|
-
-- Grant execute to the monitoring user
|
|
123
|
-
grant execute on function postgres_ai.explain_generic(text, text, text) to {{ROLE_IDENT}};
|
|
124
|
-
|
|
125
5
|
/*
|
|
126
6
|
* table_describe
|
|
127
7
|
*
|
|
@@ -435,5 +315,3 @@ comment on function postgres_ai.table_describe(text) is
|
|
|
435
315
|
'Returns comprehensive table information in compact text format for LLM analysis';
|
|
436
316
|
|
|
437
317
|
grant execute on function postgres_ai.table_describe(text) to {{ROLE_IDENT}};
|
|
438
|
-
|
|
439
|
-
|
|
@@ -0,0 +1,339 @@
|
|
|
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
|
+
function runCli(args: string[], env: Record<string, string> = {}) {
|
|
7
|
+
const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
8
|
+
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
|
|
9
|
+
const result = Bun.spawnSync([bunBin, cliPath, ...args], {
|
|
10
|
+
env: { ...process.env, ...env },
|
|
11
|
+
});
|
|
12
|
+
return {
|
|
13
|
+
status: result.exitCode,
|
|
14
|
+
stdout: new TextDecoder().decode(result.stdout),
|
|
15
|
+
stderr: new TextDecoder().decode(result.stderr),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Async spawn — MUST be used for any test that hits the in-process fake server:
|
|
20
|
+
// Bun.spawnSync would block this process's event loop, so the fake `Bun.serve`
|
|
21
|
+
// could never answer the subprocess's request (deadlock).
|
|
22
|
+
async function runCliAsync(args: string[], env: Record<string, string> = {}) {
|
|
23
|
+
const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
24
|
+
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
|
|
25
|
+
const proc = Bun.spawn([bunBin, cliPath, ...args], {
|
|
26
|
+
env: { ...process.env, ...env },
|
|
27
|
+
stdout: "pipe",
|
|
28
|
+
stderr: "pipe",
|
|
29
|
+
});
|
|
30
|
+
const [status, stdout, stderr] = await Promise.all([
|
|
31
|
+
proc.exited,
|
|
32
|
+
new Response(proc.stdout).text(),
|
|
33
|
+
new Response(proc.stderr).text(),
|
|
34
|
+
]);
|
|
35
|
+
return { status, stdout, stderr };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isolatedEnv(extra: Record<string, string> = {}) {
|
|
39
|
+
const cfgHome = mkdtempSync(resolve(tmpdir(), "postgresai-dblab-test-"));
|
|
40
|
+
return { XDG_CONFIG_HOME: cfgHome, HOME: cfgHome, ...extra };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface Recorded {
|
|
44
|
+
method: string;
|
|
45
|
+
pathname: string;
|
|
46
|
+
search: string;
|
|
47
|
+
bodyJson: any | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Fake Platform API: serves the `/rpc/projects_list` resolver listing (rows
|
|
52
|
+
* carry `dblab_instance_id`, S.1/DB.4) and echoes every `/rpc/dblab_api_call`
|
|
53
|
+
* proxy call so tests can assert the action/method/payload the CLI produced.
|
|
54
|
+
*/
|
|
55
|
+
async function startFakeApi(projects?: unknown[]) {
|
|
56
|
+
const requests: Recorded[] = [];
|
|
57
|
+
const rows = projects ?? [
|
|
58
|
+
{ project_id: 12, alias: "main-db", name: "Main DB", label: "Main DB", joe_ready: true, tunnel: false, instance_id: 1, dblab_instance_id: 7 },
|
|
59
|
+
];
|
|
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 = req.method === "POST" ? await req.text() : "";
|
|
67
|
+
let bodyJson: any | null = null;
|
|
68
|
+
try { bodyJson = bodyText ? JSON.parse(bodyText) : null; } catch { bodyJson = null; }
|
|
69
|
+
requests.push({ method: req.method, pathname: url.pathname, search: url.search, bodyJson });
|
|
70
|
+
|
|
71
|
+
if (req.method === "POST" && url.pathname.endsWith("/rpc/projects_list")) {
|
|
72
|
+
return new Response(JSON.stringify(rows), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
73
|
+
}
|
|
74
|
+
if (req.method === "POST" && url.pathname.endsWith("/rpc/dblab_api_call")) {
|
|
75
|
+
// Echo a plausible reply keyed off the action.
|
|
76
|
+
const action = String(bodyJson?.action ?? "");
|
|
77
|
+
const reply = action === "/clones" || action.startsWith("/branches") || action.startsWith("/snapshots") || action.endsWith("/log")
|
|
78
|
+
? []
|
|
79
|
+
: { instance_id: bodyJson?.instance_id, action, method: bodyJson?.method, ok: true };
|
|
80
|
+
return new Response(JSON.stringify(reply), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
81
|
+
}
|
|
82
|
+
return new Response("not found", { status: 404 });
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const baseUrl = `http://${server.hostname}:${server.port}/api/general`;
|
|
87
|
+
return {
|
|
88
|
+
baseUrl,
|
|
89
|
+
requests,
|
|
90
|
+
proxyCalls: () => requests.filter((r) => r.pathname.endsWith("/rpc/dblab_api_call")),
|
|
91
|
+
resolverCalls: () => requests.filter((r) => r.pathname.endsWith("/rpc/projects_list")),
|
|
92
|
+
stop: () => server.stop(true),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe("CLI DBLab companion command groups", () => {
|
|
97
|
+
test("top-level help lists clone, branch, and snapshot groups", () => {
|
|
98
|
+
const r = runCli(["--help"], isolatedEnv());
|
|
99
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
100
|
+
expect(out).toContain("clone");
|
|
101
|
+
expect(out).toContain("branch");
|
|
102
|
+
expect(out).toContain("snapshot");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("clone help exposes create/list/status/reset/destroy", () => {
|
|
106
|
+
const r = runCli(["clone", "--help"], isolatedEnv());
|
|
107
|
+
expect(r.status).toBe(0);
|
|
108
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
109
|
+
expect(out).toContain("create");
|
|
110
|
+
expect(out).toContain("list");
|
|
111
|
+
expect(out).toContain("status");
|
|
112
|
+
expect(out).toContain("reset");
|
|
113
|
+
expect(out).toContain("destroy");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("branch help exposes list/create/delete/log", () => {
|
|
117
|
+
const r = runCli(["branch", "--help"], isolatedEnv());
|
|
118
|
+
expect(r.status).toBe(0);
|
|
119
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
120
|
+
expect(out).toContain("list");
|
|
121
|
+
expect(out).toContain("create");
|
|
122
|
+
expect(out).toContain("delete");
|
|
123
|
+
expect(out).toContain("log");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("snapshot help exposes list/create/destroy", () => {
|
|
127
|
+
const r = runCli(["snapshot", "--help"], isolatedEnv());
|
|
128
|
+
expect(r.status).toBe(0);
|
|
129
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
130
|
+
expect(out).toContain("list");
|
|
131
|
+
expect(out).toContain("create");
|
|
132
|
+
expect(out).toContain("destroy");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("clone create fails fast without an API key", () => {
|
|
136
|
+
const r = runCli(["clone", "create", "--project", "main-db"], isolatedEnv());
|
|
137
|
+
expect(r.status).toBe(1);
|
|
138
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("clone list without --project errors", () => {
|
|
142
|
+
const r = runCli(["clone", "list"], isolatedEnv({ PGAI_API_KEY: "test-key" }));
|
|
143
|
+
expect(r.status).not.toBe(0);
|
|
144
|
+
expect(`${r.stdout}\n${r.stderr}`.toLowerCase()).toContain("project");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("clone create resolves the alias then proxies /clone POST", async () => {
|
|
148
|
+
const api = await startFakeApi();
|
|
149
|
+
try {
|
|
150
|
+
const r = await runCliAsync(
|
|
151
|
+
["clone", "create", "--project", "main-db", "--json"],
|
|
152
|
+
isolatedEnv({ PGAI_API_KEY: "test-key", PGAI_API_BASE_URL: api.baseUrl })
|
|
153
|
+
);
|
|
154
|
+
expect(r.status).toBe(0);
|
|
155
|
+
// The resolver GET happened.
|
|
156
|
+
expect(api.resolverCalls().length).toBe(1);
|
|
157
|
+
// Exactly one proxy call, with the right action/method/instance.
|
|
158
|
+
const proxied = api.proxyCalls();
|
|
159
|
+
expect(proxied.length).toBe(1);
|
|
160
|
+
expect(proxied[0].bodyJson.instance_id).toBe("7");
|
|
161
|
+
expect(proxied[0].bodyJson.action).toBe("/clone");
|
|
162
|
+
expect(proxied[0].bodyJson.method).toBe("post");
|
|
163
|
+
expect(proxied[0].bodyJson.data).toEqual({ protected: false });
|
|
164
|
+
} finally {
|
|
165
|
+
api.stop();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("clone create maps branch/snapshot/protected into the payload", async () => {
|
|
170
|
+
const api = await startFakeApi();
|
|
171
|
+
try {
|
|
172
|
+
const r = await runCliAsync(
|
|
173
|
+
["clone", "create", "--project", "main-db", "--branch", "feature-idx", "--snapshot", "s-9", "--protected", "--json"],
|
|
174
|
+
isolatedEnv({ PGAI_API_KEY: "test-key", PGAI_API_BASE_URL: api.baseUrl })
|
|
175
|
+
);
|
|
176
|
+
expect(r.status).toBe(0);
|
|
177
|
+
const body = api.proxyCalls()[0].bodyJson;
|
|
178
|
+
expect(body.data).toEqual({ protected: true, branch: "feature-idx", snapshot: { id: "s-9" } });
|
|
179
|
+
} finally {
|
|
180
|
+
api.stop();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("clone reset proxies /clone/<id>/reset POST", async () => {
|
|
185
|
+
const api = await startFakeApi();
|
|
186
|
+
try {
|
|
187
|
+
const r = await runCliAsync(
|
|
188
|
+
["clone", "reset", "c-8f21", "--project", "main-db", "--json"],
|
|
189
|
+
isolatedEnv({ PGAI_API_KEY: "test-key", PGAI_API_BASE_URL: api.baseUrl })
|
|
190
|
+
);
|
|
191
|
+
expect(r.status).toBe(0);
|
|
192
|
+
const body = api.proxyCalls()[0].bodyJson;
|
|
193
|
+
expect(body.action).toBe("/clone/c-8f21/reset");
|
|
194
|
+
expect(body.method).toBe("post");
|
|
195
|
+
expect(body.data).toEqual({ latest: true });
|
|
196
|
+
} finally {
|
|
197
|
+
api.stop();
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("clone destroy proxies /clone/<id> DELETE and resolves a numeric project id", async () => {
|
|
202
|
+
const api = await startFakeApi();
|
|
203
|
+
try {
|
|
204
|
+
const r = await runCliAsync(
|
|
205
|
+
["clone", "destroy", "c-8f21", "--project", "12", "--json"],
|
|
206
|
+
isolatedEnv({ PGAI_API_KEY: "test-key", PGAI_API_BASE_URL: api.baseUrl })
|
|
207
|
+
);
|
|
208
|
+
expect(r.status).toBe(0);
|
|
209
|
+
const body = api.proxyCalls()[0].bodyJson;
|
|
210
|
+
expect(body.instance_id).toBe("7"); // resolved from project_id 12
|
|
211
|
+
expect(body.action).toBe("/clone/c-8f21");
|
|
212
|
+
expect(body.method).toBe("delete");
|
|
213
|
+
} finally {
|
|
214
|
+
api.stop();
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("branch create proxies /branch POST with branchName + snapshotID", async () => {
|
|
219
|
+
const api = await startFakeApi();
|
|
220
|
+
try {
|
|
221
|
+
const r = await runCliAsync(
|
|
222
|
+
["branch", "create", "feature-idx", "--project", "main-db", "--snapshot", "latest", "--json"],
|
|
223
|
+
isolatedEnv({ PGAI_API_KEY: "test-key", PGAI_API_BASE_URL: api.baseUrl })
|
|
224
|
+
);
|
|
225
|
+
expect(r.status).toBe(0);
|
|
226
|
+
const body = api.proxyCalls()[0].bodyJson;
|
|
227
|
+
expect(body.action).toBe("/branch");
|
|
228
|
+
expect(body.method).toBe("post");
|
|
229
|
+
expect(body.data).toEqual({ branchName: "feature-idx", snapshotID: "latest" });
|
|
230
|
+
} finally {
|
|
231
|
+
api.stop();
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("branch log proxies /branch/<name>/log GET", async () => {
|
|
236
|
+
const api = await startFakeApi();
|
|
237
|
+
try {
|
|
238
|
+
const r = await runCliAsync(
|
|
239
|
+
["branch", "log", "feature-idx", "--project", "main-db", "--json"],
|
|
240
|
+
isolatedEnv({ PGAI_API_KEY: "test-key", PGAI_API_BASE_URL: api.baseUrl })
|
|
241
|
+
);
|
|
242
|
+
expect(r.status).toBe(0);
|
|
243
|
+
const body = api.proxyCalls()[0].bodyJson;
|
|
244
|
+
expect(body.action).toBe("/branch/feature-idx/log");
|
|
245
|
+
expect(body.method).toBe("get");
|
|
246
|
+
} finally {
|
|
247
|
+
api.stop();
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("snapshot list proxies /snapshots GET", async () => {
|
|
252
|
+
const api = await startFakeApi();
|
|
253
|
+
try {
|
|
254
|
+
const r = await runCliAsync(
|
|
255
|
+
["snapshot", "list", "--project", "main-db", "--json"],
|
|
256
|
+
isolatedEnv({ PGAI_API_KEY: "test-key", PGAI_API_BASE_URL: api.baseUrl })
|
|
257
|
+
);
|
|
258
|
+
expect(r.status).toBe(0);
|
|
259
|
+
const body = api.proxyCalls()[0].bodyJson;
|
|
260
|
+
expect(body.action).toBe("/snapshots");
|
|
261
|
+
expect(body.method).toBe("get");
|
|
262
|
+
} finally {
|
|
263
|
+
api.stop();
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("snapshot create proxies /branch/snapshot POST with cloneID", async () => {
|
|
268
|
+
const api = await startFakeApi();
|
|
269
|
+
try {
|
|
270
|
+
const r = await runCliAsync(
|
|
271
|
+
["snapshot", "create", "--project", "main-db", "--clone", "c-8f21", "--message", "before idx", "--json"],
|
|
272
|
+
isolatedEnv({ PGAI_API_KEY: "test-key", PGAI_API_BASE_URL: api.baseUrl })
|
|
273
|
+
);
|
|
274
|
+
expect(r.status).toBe(0);
|
|
275
|
+
const body = api.proxyCalls()[0].bodyJson;
|
|
276
|
+
expect(body.action).toBe("/branch/snapshot");
|
|
277
|
+
expect(body.data).toEqual({ cloneID: "c-8f21", message: "before idx" });
|
|
278
|
+
} finally {
|
|
279
|
+
api.stop();
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("snapshot destroy proxies /snapshot/<id>?force=<bool> DELETE", async () => {
|
|
284
|
+
const api = await startFakeApi();
|
|
285
|
+
try {
|
|
286
|
+
const r = await runCliAsync(
|
|
287
|
+
["snapshot", "destroy", "s-1", "--project", "main-db", "--force", "--json"],
|
|
288
|
+
isolatedEnv({ PGAI_API_KEY: "test-key", PGAI_API_BASE_URL: api.baseUrl })
|
|
289
|
+
);
|
|
290
|
+
expect(r.status).toBe(0);
|
|
291
|
+
const body = api.proxyCalls()[0].bodyJson;
|
|
292
|
+
expect(body.action).toBe("/snapshot/s-1?force=true");
|
|
293
|
+
expect(body.method).toBe("delete");
|
|
294
|
+
} finally {
|
|
295
|
+
api.stop();
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("an unknown project alias exits 1 with a helpful message (no proxy call)", async () => {
|
|
300
|
+
const api = await startFakeApi();
|
|
301
|
+
try {
|
|
302
|
+
const r = await runCliAsync(
|
|
303
|
+
["clone", "list", "--project", "does-not-exist"],
|
|
304
|
+
isolatedEnv({ PGAI_API_KEY: "test-key", PGAI_API_BASE_URL: api.baseUrl })
|
|
305
|
+
);
|
|
306
|
+
expect(r.status).toBe(1);
|
|
307
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("No DBLab instance found");
|
|
308
|
+
expect(api.proxyCalls().length).toBe(0);
|
|
309
|
+
} finally {
|
|
310
|
+
api.stop();
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("a backend scope error (403) surfaces and exits 1", async () => {
|
|
315
|
+
// Fake server that 403s the proxy call to emulate a missing joe:admin scope.
|
|
316
|
+
const server = Bun.serve({
|
|
317
|
+
hostname: "127.0.0.1",
|
|
318
|
+
port: 0,
|
|
319
|
+
async fetch(req) {
|
|
320
|
+
const url = new URL(req.url);
|
|
321
|
+
if (req.method === "POST" && url.pathname.endsWith("/rpc/projects_list")) {
|
|
322
|
+
return new Response(JSON.stringify([{ project_id: 12, alias: "main-db", dblab_instance_id: 7 }]), { status: 200 });
|
|
323
|
+
}
|
|
324
|
+
return new Response('{"message":"insufficient scope: joe:admin required"}', { status: 403 });
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
try {
|
|
328
|
+
const baseUrl = `http://${server.hostname}:${server.port}/api/general`;
|
|
329
|
+
const r = await runCliAsync(
|
|
330
|
+
["clone", "destroy", "c-1", "--project", "main-db"],
|
|
331
|
+
isolatedEnv({ PGAI_API_KEY: "test-key", PGAI_API_BASE_URL: baseUrl })
|
|
332
|
+
);
|
|
333
|
+
expect(r.status).toBe(1);
|
|
334
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("Failed to destroy clone");
|
|
335
|
+
} finally {
|
|
336
|
+
server.stop(true);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
});
|