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.
@@ -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
+ }