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/dist/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
|
-
|
|
@@ -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
|
-
|
package/lib/dblab.ts
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DBLab companion command surface (Joe API v2 · SPEC §8).
|
|
3
|
+
*
|
|
4
|
+
* Thin CLI/MCP client that **proxies the existing Platform DBLab API** — the very
|
|
5
|
+
* same endpoints the Console (React) drives — to manage a project's own thin
|
|
6
|
+
* clones, branches, and snapshots. Every verb goes through the generic proxy rpc
|
|
7
|
+
* `v1.dblab_api_call(instance_id, method, action, data)` (the first-class
|
|
8
|
+
* `dblab_clone_*` rpcs are deprecated in favour of it), mirroring
|
|
9
|
+
* `packages/platform/src/api/{clones,branches,snapshots}/*` in the platform repo:
|
|
10
|
+
*
|
|
11
|
+
* POST {base}/rpc/dblab_api_call
|
|
12
|
+
* body: { instance_id, action, method, data? }
|
|
13
|
+
*
|
|
14
|
+
* `method` is a lowercase HTTP verb, `action` is the leading-slash DBLab engine
|
|
15
|
+
* path, and `data` (mutations only) is a nested JSON object — exactly the wire
|
|
16
|
+
* shape the Console sends. No net-new backend: this is the same pattern as the
|
|
17
|
+
* `postgresai` CLI already proxying issues/reports.
|
|
18
|
+
*
|
|
19
|
+
* Addressing is **project-centric**: callers pass `--project <id|alias>` and the
|
|
20
|
+
* project's single DBLab instance is resolved to an `instance_id` (see
|
|
21
|
+
* `resolveDblabInstanceId`). Destructive verbs (clone reset/destroy, branch
|
|
22
|
+
* delete, snapshot destroy) are gated server-side by the `joe:admin` scope; the
|
|
23
|
+
* read/create verbs by `joe:plan` — a `PT403` surfaces as an HTTP 403 here.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { formatHttpError, maskSecret, normalizeBaseUrl } from "./util";
|
|
27
|
+
import { listProjects, type ProjectListItem } from "./joe";
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Types
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/** A row of the `v1.dblab_instances` view (only the fields the resolver needs). */
|
|
34
|
+
export interface DblabInstanceRow {
|
|
35
|
+
/** The DBLab instance id — this is the `instance_id` passed to `dblab_api_call`. */
|
|
36
|
+
id: number | string;
|
|
37
|
+
project_id?: number | null;
|
|
38
|
+
project_name?: string | null;
|
|
39
|
+
project_alias?: string | null;
|
|
40
|
+
project_label_or_name?: string | null;
|
|
41
|
+
org_id?: number | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface DblabCommon {
|
|
45
|
+
apiKey: string;
|
|
46
|
+
apiBaseUrl: string;
|
|
47
|
+
/** Resolved DBLab instance id (see `resolveDblabInstanceId`). */
|
|
48
|
+
instanceId: string;
|
|
49
|
+
debug?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Project → DBLab instance resolution
|
|
54
|
+
//
|
|
55
|
+
// Resolution rides the org-level projects listing rpc (`v1.projects_list`,
|
|
56
|
+
// S.1): its rows carry each project's `dblab_instance_id` (DB.4), and the rpc
|
|
57
|
+
// authenticates with the CLI's opaque org `access-token` header. The previous
|
|
58
|
+
// interim resolver read the `v1.dblab_instances` PostgREST view, which is
|
|
59
|
+
// JWT-scoped — permanently empty/403 for a token-only caller — so it never
|
|
60
|
+
// worked outside mocks and was dropped at wiring (dedupe note resolved).
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/** A bare numeric `--project` value is a project id; anything else is an alias/name. */
|
|
64
|
+
export function isNumericProjectRef(ref: string): boolean {
|
|
65
|
+
return /^[0-9]+$/.test(ref.trim());
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ResolveDblabInstanceParams {
|
|
69
|
+
apiKey: string;
|
|
70
|
+
apiBaseUrl: string;
|
|
71
|
+
/** `--project <id|alias>` — a numeric project id, or a project alias/name. */
|
|
72
|
+
project: string;
|
|
73
|
+
/** Optional org scope (narrows the projects listing). */
|
|
74
|
+
orgId?: number;
|
|
75
|
+
debug?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolve `--project <id|alias>` to the project's single DBLab `instance_id`.
|
|
80
|
+
*
|
|
81
|
+
* Lists the org's projects via `v1.projects_list` (the same call behind
|
|
82
|
+
* `pgai projects`): a numeric ref matches `project_id`; anything else matches
|
|
83
|
+
* `alias` / `name` / `label` (case-insensitive). Each project has at most one
|
|
84
|
+
* active DBLab instance (`dblab_instance_id`). The returned id is a string so
|
|
85
|
+
* a 64-bit id survives without JS number-precision loss.
|
|
86
|
+
*/
|
|
87
|
+
export async function resolveDblabInstanceId(params: ResolveDblabInstanceParams): Promise<string> {
|
|
88
|
+
const { apiKey, apiBaseUrl, orgId, debug } = params;
|
|
89
|
+
if (!apiKey) {
|
|
90
|
+
throw new Error("API key is required");
|
|
91
|
+
}
|
|
92
|
+
const ref = String(params.project ?? "").trim();
|
|
93
|
+
if (!ref) {
|
|
94
|
+
throw new Error("project is required (--project <id|alias>)");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let projects: ProjectListItem[];
|
|
98
|
+
try {
|
|
99
|
+
projects = await listProjects({ apiKey, apiBaseUrl, orgId, debug });
|
|
100
|
+
} catch (err) {
|
|
101
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
102
|
+
throw new Error(`Failed to resolve project's DBLab instance: ${message}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const numeric = isNumericProjectRef(ref);
|
|
106
|
+
const needle = ref.toLowerCase();
|
|
107
|
+
const match = projects.find((p) => {
|
|
108
|
+
if (numeric) {
|
|
109
|
+
return String(p.project_id) === ref;
|
|
110
|
+
}
|
|
111
|
+
return (
|
|
112
|
+
(p.alias !== null && p.alias.toLowerCase() === needle) ||
|
|
113
|
+
(p.name !== null && p.name.toLowerCase() === needle) ||
|
|
114
|
+
(p.label !== null && p.label.toLowerCase() === needle)
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (!match) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`No DBLab instance found for project '${ref}'. Run 'pgai projects' to see available projects.`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (match.dblab_instance_id == null) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`Project '${ref}' has no active DBLab instance. Register a DBLab instance for it in the Console first.`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return String(match.dblab_instance_id);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Low-level proxy caller — POST /rpc/dblab_api_call
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
interface DblabApiCallParams {
|
|
136
|
+
apiKey: string;
|
|
137
|
+
apiBaseUrl: string;
|
|
138
|
+
instanceId: string;
|
|
139
|
+
/** Leading-slash DBLab engine path, e.g. `/clone`, `/branches`, `/snapshots`. */
|
|
140
|
+
action: string;
|
|
141
|
+
/** Lowercase HTTP verb forwarded to the DBLab engine: get/post/patch/delete. */
|
|
142
|
+
method: string;
|
|
143
|
+
/** Optional request body (mutations only), forwarded as a nested JSON object. */
|
|
144
|
+
data?: Record<string, unknown>;
|
|
145
|
+
operation: string;
|
|
146
|
+
debug?: boolean;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Proxy a single call to the DBLab engine via `v1.dblab_api_call`, mirroring the
|
|
151
|
+
* Console's `request('/rpc/dblab_api_call', { body: {instance_id, action, method,
|
|
152
|
+
* data} })`. Returns the parsed JSON reply, or throws a formatted HTTP error.
|
|
153
|
+
*/
|
|
154
|
+
async function callDblabApi<T>(params: DblabApiCallParams): Promise<T> {
|
|
155
|
+
const { apiKey, apiBaseUrl, instanceId, action, method, data, operation, debug } = params;
|
|
156
|
+
if (!apiKey) {
|
|
157
|
+
throw new Error("API key is required");
|
|
158
|
+
}
|
|
159
|
+
if (!instanceId) {
|
|
160
|
+
throw new Error("instanceId is required");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const base = normalizeBaseUrl(apiBaseUrl);
|
|
164
|
+
const url = new URL(`${base}/rpc/dblab_api_call`);
|
|
165
|
+
|
|
166
|
+
const bodyObj: Record<string, unknown> = {
|
|
167
|
+
instance_id: instanceId,
|
|
168
|
+
action,
|
|
169
|
+
method,
|
|
170
|
+
};
|
|
171
|
+
if (data !== undefined) {
|
|
172
|
+
bodyObj.data = data;
|
|
173
|
+
}
|
|
174
|
+
const body = JSON.stringify(bodyObj);
|
|
175
|
+
|
|
176
|
+
const headers: Record<string, string> = {
|
|
177
|
+
"access-token": apiKey,
|
|
178
|
+
"Content-Type": "application/json",
|
|
179
|
+
"Connection": "close",
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
if (debug) {
|
|
183
|
+
const debugHeaders = { ...headers, "access-token": maskSecret(apiKey) };
|
|
184
|
+
console.error(`Debug: POST URL: ${url.toString()}`);
|
|
185
|
+
console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
|
|
186
|
+
console.error(`Debug: Request body: ${body}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const response = await fetch(url.toString(), { method: "POST", headers, body });
|
|
190
|
+
const text = await response.text();
|
|
191
|
+
|
|
192
|
+
if (debug) {
|
|
193
|
+
console.error(`Debug: Response status: ${response.status}`);
|
|
194
|
+
console.error(`Debug: Response body: ${text}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!response.ok) {
|
|
198
|
+
// PostgREST maps custom `PTxyz` sqlstates to HTTP status `xyz`, so a missing
|
|
199
|
+
// `joe:admin` scope on a destructive verb surfaces here as HTTP 403.
|
|
200
|
+
throw new Error(formatHttpError(operation, response.status, text));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Some DBLab actions (reset/destroy) reply with an empty body on success.
|
|
204
|
+
if (text.trim() === "") {
|
|
205
|
+
return null as unknown as T;
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
return JSON.parse(text) as T;
|
|
209
|
+
} catch {
|
|
210
|
+
throw new Error(`${operation}: failed to parse response: ${text}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ===========================================================================
|
|
215
|
+
// Clones — create / list / status / reset / destroy
|
|
216
|
+
// ===========================================================================
|
|
217
|
+
|
|
218
|
+
export interface CreateCloneParams extends DblabCommon {
|
|
219
|
+
/** Optional caller-chosen clone id (DBLab generates one when omitted). */
|
|
220
|
+
cloneId?: string;
|
|
221
|
+
/** Branch to clone from. */
|
|
222
|
+
branch?: string;
|
|
223
|
+
/** Snapshot id to clone from. */
|
|
224
|
+
snapshotId?: string;
|
|
225
|
+
/** Clone DB user (paired with `dbPassword`). */
|
|
226
|
+
dbUser?: string;
|
|
227
|
+
/** Clone DB password (paired with `dbUser`). */
|
|
228
|
+
dbPassword?: string;
|
|
229
|
+
/** Protect the clone from auto-deletion. */
|
|
230
|
+
isProtected?: boolean;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Create a thin clone — `/clone` POST (Console `createClone`). */
|
|
234
|
+
export async function createClone<T = unknown>(params: CreateCloneParams): Promise<T> {
|
|
235
|
+
const { apiKey, apiBaseUrl, instanceId, cloneId, branch, snapshotId, dbUser, dbPassword, isProtected, debug } = params;
|
|
236
|
+
const data: Record<string, unknown> = { protected: Boolean(isProtected) };
|
|
237
|
+
if (cloneId) data.id = cloneId;
|
|
238
|
+
if (branch) data.branch = branch;
|
|
239
|
+
if (snapshotId) data.snapshot = { id: snapshotId };
|
|
240
|
+
if (dbUser && dbPassword) data.db = { username: dbUser, password: dbPassword };
|
|
241
|
+
return callDblabApi<T>({
|
|
242
|
+
apiKey, apiBaseUrl, instanceId,
|
|
243
|
+
action: "/clone", method: "post", data,
|
|
244
|
+
operation: "Failed to create clone", debug,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** List clones — `/clones` GET. */
|
|
249
|
+
export async function listClones<T = unknown>(params: DblabCommon): Promise<T> {
|
|
250
|
+
const { apiKey, apiBaseUrl, instanceId, debug } = params;
|
|
251
|
+
return callDblabApi<T>({
|
|
252
|
+
apiKey, apiBaseUrl, instanceId,
|
|
253
|
+
action: "/clones", method: "get",
|
|
254
|
+
operation: "Failed to list clones", debug,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export interface CloneIdParams extends DblabCommon {
|
|
259
|
+
cloneId: string;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Get a clone's status — `/clone/<id>` GET (Console `getClone`). */
|
|
263
|
+
export async function getClone<T = unknown>(params: CloneIdParams): Promise<T> {
|
|
264
|
+
const { apiKey, apiBaseUrl, instanceId, cloneId, debug } = params;
|
|
265
|
+
if (!cloneId) throw new Error("cloneId is required");
|
|
266
|
+
return callDblabApi<T>({
|
|
267
|
+
apiKey, apiBaseUrl, instanceId,
|
|
268
|
+
action: `/clone/${encodeURIComponent(cloneId)}`, method: "get",
|
|
269
|
+
operation: "Failed to get clone", debug,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export interface ResetCloneParams extends CloneIdParams {
|
|
274
|
+
/** Snapshot to reset to; when omitted, resets to the latest snapshot. */
|
|
275
|
+
snapshotId?: string;
|
|
276
|
+
/** Reset to the latest snapshot (defaults true when no `snapshotId` is given). */
|
|
277
|
+
latest?: boolean;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Reset a clone to a pristine snapshot — `/clone/<id>/reset` POST (Console `resetClone`). */
|
|
281
|
+
export async function resetClone<T = unknown>(params: ResetCloneParams): Promise<T> {
|
|
282
|
+
const { apiKey, apiBaseUrl, instanceId, cloneId, snapshotId, latest, debug } = params;
|
|
283
|
+
if (!cloneId) throw new Error("cloneId is required");
|
|
284
|
+
const data: Record<string, unknown> = { latest: latest ?? !snapshotId };
|
|
285
|
+
if (snapshotId) data.snapshotID = snapshotId;
|
|
286
|
+
return callDblabApi<T>({
|
|
287
|
+
apiKey, apiBaseUrl, instanceId,
|
|
288
|
+
action: `/clone/${encodeURIComponent(cloneId)}/reset`, method: "post", data,
|
|
289
|
+
operation: "Failed to reset clone", debug,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Destroy a clone — `/clone/<id>` DELETE (Console `destroyClone`). */
|
|
294
|
+
export async function destroyClone<T = unknown>(params: CloneIdParams): Promise<T> {
|
|
295
|
+
const { apiKey, apiBaseUrl, instanceId, cloneId, debug } = params;
|
|
296
|
+
if (!cloneId) throw new Error("cloneId is required");
|
|
297
|
+
return callDblabApi<T>({
|
|
298
|
+
apiKey, apiBaseUrl, instanceId,
|
|
299
|
+
action: `/clone/${encodeURIComponent(cloneId)}`, method: "delete",
|
|
300
|
+
operation: "Failed to destroy clone", debug,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ===========================================================================
|
|
305
|
+
// Branches — list / create / delete / log
|
|
306
|
+
// ===========================================================================
|
|
307
|
+
|
|
308
|
+
/** List branches — `/branches` GET (Console `getBranches`). */
|
|
309
|
+
export async function listBranches<T = unknown>(params: DblabCommon): Promise<T> {
|
|
310
|
+
const { apiKey, apiBaseUrl, instanceId, debug } = params;
|
|
311
|
+
return callDblabApi<T>({
|
|
312
|
+
apiKey, apiBaseUrl, instanceId,
|
|
313
|
+
action: "/branches", method: "get",
|
|
314
|
+
operation: "Failed to list branches", debug,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export interface CreateBranchParams extends DblabCommon {
|
|
319
|
+
branchName: string;
|
|
320
|
+
/** Parent branch to fork from. */
|
|
321
|
+
baseBranch?: string;
|
|
322
|
+
/** Snapshot id to base the branch on. */
|
|
323
|
+
snapshotId?: string;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Create a branch — `/branch` POST (Console `createBranch`). */
|
|
327
|
+
export async function createBranch<T = unknown>(params: CreateBranchParams): Promise<T> {
|
|
328
|
+
const { apiKey, apiBaseUrl, instanceId, branchName, baseBranch, snapshotId, debug } = params;
|
|
329
|
+
if (!branchName) throw new Error("branchName is required");
|
|
330
|
+
const data: Record<string, unknown> = { branchName };
|
|
331
|
+
if (baseBranch) data.baseBranch = baseBranch;
|
|
332
|
+
if (snapshotId) data.snapshotID = snapshotId;
|
|
333
|
+
return callDblabApi<T>({
|
|
334
|
+
apiKey, apiBaseUrl, instanceId,
|
|
335
|
+
action: "/branch", method: "post", data,
|
|
336
|
+
operation: "Failed to create branch", debug,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export interface BranchNameParams extends DblabCommon {
|
|
341
|
+
branchName: string;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Delete a branch — `/branch/<name>` DELETE (Console `deleteBranch`). */
|
|
345
|
+
export async function deleteBranch<T = unknown>(params: BranchNameParams): Promise<T> {
|
|
346
|
+
const { apiKey, apiBaseUrl, instanceId, branchName, debug } = params;
|
|
347
|
+
if (!branchName) throw new Error("branchName is required");
|
|
348
|
+
return callDblabApi<T>({
|
|
349
|
+
apiKey, apiBaseUrl, instanceId,
|
|
350
|
+
action: `/branch/${encodeURIComponent(branchName)}`, method: "delete",
|
|
351
|
+
operation: "Failed to delete branch", debug,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** List a branch's snapshot log — `/branch/<name>/log` GET (Console `getSnapshotList`). */
|
|
356
|
+
export async function branchLog<T = unknown>(params: BranchNameParams): Promise<T> {
|
|
357
|
+
const { apiKey, apiBaseUrl, instanceId, branchName, debug } = params;
|
|
358
|
+
if (!branchName) throw new Error("branchName is required");
|
|
359
|
+
return callDblabApi<T>({
|
|
360
|
+
apiKey, apiBaseUrl, instanceId,
|
|
361
|
+
action: `/branch/${encodeURIComponent(branchName)}/log`, method: "get",
|
|
362
|
+
operation: "Failed to fetch branch log", debug,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ===========================================================================
|
|
367
|
+
// Snapshots — list / create / destroy
|
|
368
|
+
// ===========================================================================
|
|
369
|
+
|
|
370
|
+
export interface ListSnapshotsParams extends DblabCommon {
|
|
371
|
+
/** Filter snapshots by branch. */
|
|
372
|
+
branchName?: string;
|
|
373
|
+
/** Filter snapshots by dataset. */
|
|
374
|
+
dataset?: string;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** List snapshots — `/snapshots[?branch=&dataset=]` GET (Console `getSnapshots`). */
|
|
378
|
+
export async function listSnapshots<T = unknown>(params: ListSnapshotsParams): Promise<T> {
|
|
379
|
+
const { apiKey, apiBaseUrl, instanceId, branchName, dataset, debug } = params;
|
|
380
|
+
const qs = new URLSearchParams();
|
|
381
|
+
const branch = branchName?.trim();
|
|
382
|
+
if (branch) qs.append("branch", branch);
|
|
383
|
+
if (dataset) qs.append("dataset", dataset);
|
|
384
|
+
const action = `/snapshots${qs.toString() ? `?${qs.toString()}` : ""}`;
|
|
385
|
+
return callDblabApi<T>({
|
|
386
|
+
apiKey, apiBaseUrl, instanceId,
|
|
387
|
+
action, method: "get",
|
|
388
|
+
operation: "Failed to list snapshots", debug,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export interface CreateSnapshotParams extends DblabCommon {
|
|
393
|
+
/** Clone to snapshot. */
|
|
394
|
+
cloneId: string;
|
|
395
|
+
/** Optional snapshot message. */
|
|
396
|
+
message?: string;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Create a snapshot from a clone — `/branch/snapshot` POST (Console `createSnapshot`). */
|
|
400
|
+
export async function createSnapshot<T = unknown>(params: CreateSnapshotParams): Promise<T> {
|
|
401
|
+
const { apiKey, apiBaseUrl, instanceId, cloneId, message, debug } = params;
|
|
402
|
+
if (!cloneId) throw new Error("cloneId is required");
|
|
403
|
+
const data: Record<string, unknown> = { cloneID: cloneId };
|
|
404
|
+
if (message) data.message = message;
|
|
405
|
+
return callDblabApi<T>({
|
|
406
|
+
apiKey, apiBaseUrl, instanceId,
|
|
407
|
+
action: "/branch/snapshot", method: "post", data,
|
|
408
|
+
operation: "Failed to create snapshot", debug,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export interface DestroySnapshotParams extends DblabCommon {
|
|
413
|
+
snapshotId: string;
|
|
414
|
+
/** Force-delete even when dependent clones exist. */
|
|
415
|
+
force?: boolean;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Destroy a snapshot — `/snapshot/<id>?force=<bool>` DELETE (Console `destroySnapshot`). */
|
|
419
|
+
export async function destroySnapshot<T = unknown>(params: DestroySnapshotParams): Promise<T> {
|
|
420
|
+
const { apiKey, apiBaseUrl, instanceId, snapshotId, force, debug } = params;
|
|
421
|
+
if (!snapshotId) throw new Error("snapshotId is required");
|
|
422
|
+
const action = `/snapshot/${encodeURIComponent(snapshotId)}?force=${Boolean(force)}`;
|
|
423
|
+
return callDblabApi<T>({
|
|
424
|
+
apiKey, apiBaseUrl, instanceId,
|
|
425
|
+
action, method: "delete",
|
|
426
|
+
operation: "Failed to destroy snapshot", debug,
|
|
427
|
+
});
|
|
428
|
+
}
|