postgresai 0.16.0-dev.10 → 0.16.0-dev.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -99,6 +99,46 @@ describe("Schema validation", () => {
99
99
  validateAgainstSchema(report, "F003");
100
100
  });
101
101
 
102
+ for (const checkId of ["F004", "F005"]) {
103
+ test(`${checkId} distinguishes a healthy empty result from missing schema`, async () => {
104
+ const healthyClient = createMockClient();
105
+ const healthy = await checkup.REPORT_GENERATORS[checkId](healthyClient as any, "node-01");
106
+ const healthyDb = healthy.results["node-01"].data.testdb;
107
+ expect(healthyDb.status).toEqual({ ok: true, reason: null, error: null });
108
+ validateAgainstSchema(healthy, checkId);
109
+
110
+ const missingSchemaClient = createMockClient({
111
+ bloatCapabilityRows: [
112
+ { schema_exists: false, schema_usage: false, view_exists: false, view_select: false },
113
+ ],
114
+ });
115
+ const degraded = await checkup.REPORT_GENERATORS[checkId](missingSchemaClient as any, "node-01");
116
+ const degradedDb = degraded.results["node-01"].data.testdb;
117
+ expect(degradedDb.status).toEqual({
118
+ ok: false,
119
+ reason: "missing_schema",
120
+ error: 'schema "postgres_ai" does not exist',
121
+ });
122
+ validateAgainstSchema(degraded, checkId);
123
+ });
124
+
125
+ test(`${checkId} exposes missing-grant degradation`, async () => {
126
+ const client = createMockClient({
127
+ bloatCapabilityRows: [
128
+ { schema_exists: true, schema_usage: true, view_exists: true, view_select: false },
129
+ ],
130
+ });
131
+ const report = await checkup.REPORT_GENERATORS[checkId](client as any, "node-01");
132
+ const dbEntry = report.results["node-01"].data.testdb;
133
+ expect(dbEntry.status).toEqual({
134
+ ok: false,
135
+ reason: "missing_grant",
136
+ error: "permission denied for relation postgres_ai.pg_statistic",
137
+ });
138
+ validateAgainstSchema(report, checkId);
139
+ });
140
+ }
141
+
102
142
  // Settings reports (D004, F001, G001) - single test each
103
143
  for (const checkId of ["D004", "F001", "G001"]) {
104
144
  test(`${checkId} validates against schema`, async () => {
@@ -19,6 +19,7 @@ export interface MockClientOptions {
19
19
  indexBloatRows?: any[];
20
20
  deadTuplesRows?: any[];
21
21
  vacuumStatsRows?: any[];
22
+ bloatCapabilityRows?: any[];
22
23
  deadlockStatsRows?: any[];
23
24
  pgStatStatementsExtensionRows?: any[];
24
25
  pgStatStatementsStatsRows?: any[];
@@ -61,6 +62,7 @@ export function createMockClient(options: MockClientOptions = {}) {
61
62
  indexBloatRows = [],
62
63
  deadTuplesRows = [],
63
64
  vacuumStatsRows = [],
65
+ bloatCapabilityRows = [{ schema_exists: true, schema_usage: true, view_exists: true, view_select: true }],
64
66
  deadlockStatsRows = [{ deadlocks: "0", conflicts: "0", stats_reset: null }],
65
67
  pgStatStatementsExtensionRows = [],
66
68
  pgStatStatementsStatsRows = [],
@@ -128,6 +130,9 @@ export function createMockClient(options: MockClientOptions = {}) {
128
130
  return { rows: deadTuplesRows };
129
131
  }
130
132
  // F004/F005: bloat metrics from metrics.yml
133
+ if (sql.includes("to_regnamespace('postgres_ai')") && sql.includes("view_select")) {
134
+ return { rows: bloatCapabilityRows };
135
+ }
131
136
  if (sql.includes("tag_idxname") && sql.includes("bloat_size")) {
132
137
  return { rows: indexBloatRows };
133
138
  }
package/test/util.test.ts CHANGED
@@ -101,6 +101,62 @@ describe("formatHttpError", () => {
101
101
  const msg = formatHttpError("op", 403, undefined, "Forbidden");
102
102
  expect(msg).toContain("Forbidden - access denied");
103
103
  });
104
+
105
+ // Behind an h2/h3 proxy the reason phrase is dropped (statusText is empty),
106
+ // so the PTxyz body fields are all we get — `code` and `hint` must surface.
107
+ // CLI half of https://gitlab.com/postgres-ai/platform-all/-/issues/537.
108
+ test("surfaces JSON body code and hint when the reason phrase is dropped (h2)", () => {
109
+ const msg = formatHttpError(
110
+ "Failed to submit plan command",
111
+ 403,
112
+ '{"code":"PT403","details":"AI features disabled for this org","hint":"Enable AI in AI Assistant Settings"}',
113
+ "" // h2: no reason phrase
114
+ );
115
+ expect(msg).toContain("HTTP 403");
116
+ expect(msg).toContain("PT403");
117
+ expect(msg).toContain("AI features disabled for this org");
118
+ expect(msg).toContain("Hint: Enable AI in AI Assistant Settings");
119
+ });
120
+
121
+ test("surfaces code and hint alongside a body message", () => {
122
+ const msg = formatHttpError(
123
+ "op",
124
+ 429,
125
+ '{"code":"PT429","message":"Rate limited","hint":"Retry in 60s"}'
126
+ );
127
+ expect(msg).toContain("Rate limited");
128
+ expect(msg).toContain("PT429");
129
+ expect(msg).toContain("Hint: Retry in 60s");
130
+ });
131
+
132
+ test("falls back to the raw (redacted) JSON body when no known fields are present", () => {
133
+ // Pre-existing callers (issues.ts/reports.ts/storage.ts) rely on unknown
134
+ // JSON error shapes still being surfaced — a body with none of
135
+ // message/error/details/detail/code/hint must not be silently dropped.
136
+ const msg = formatHttpError("op", 500, '{"weird_field":"boom","password":"pw-x"}');
137
+ expect(msg).toContain("HTTP 500");
138
+ expect(msg).toContain("weird_field");
139
+ expect(msg).toContain("boom");
140
+ expect(msg).not.toContain("pw-x");
141
+ expect(msg).toContain("[REDACTED]");
142
+ });
143
+
144
+ test("redacts credentials from every structured JSON error field and the reason phrase", () => {
145
+ const msg = formatHttpError(
146
+ "op",
147
+ 400,
148
+ JSON.stringify({
149
+ message: "connStr=postgresql://joe:message-pw@db/app",
150
+ details: "password=detail-pw",
151
+ hint: "token=hint-token",
152
+ }),
153
+ "dsn=postgresql://joe:reason-pw@db/app"
154
+ );
155
+ expect(msg).toContain("[REDACTED]");
156
+ for (const secret of ["message-pw", "detail-pw", "hint-token", "reason-pw"]) {
157
+ expect(msg).not.toContain(secret);
158
+ }
159
+ });
104
160
  });
105
161
 
106
162
  describe("redactSecretsForLog", () => {
@@ -141,6 +197,18 @@ describe("redactSecretsForLog", () => {
141
197
  expect(out).toContain('"ok":"keep"');
142
198
  });
143
199
 
200
+ test("redacts alternate auth and credential key names", () => {
201
+ const out = redactSecretsForLog(JSON.stringify({
202
+ auth_key: "a",
203
+ db_pass: "b",
204
+ access_key: "c",
205
+ credential: "d",
206
+ ok: "keep",
207
+ }));
208
+ for (const secret of ["a", "b", "c", "d"]) expect(out).not.toContain(`\"${secret}\"`);
209
+ expect(out).toContain('"ok":"keep"');
210
+ });
211
+
144
212
  test("traverses arrays (e.g. result rows carrying a password column)", () => {
145
213
  const out = redactSecretsForLog(JSON.stringify({ rows: [{ usename: "app", password: "s3cr3t" }] }));
146
214
  expect(out).not.toContain("s3cr3t");
@@ -151,4 +219,52 @@ describe("redactSecretsForLog", () => {
151
219
  expect(redactSecretsForLog(JSON.stringify({ password: null }))).toContain('"password":null');
152
220
  expect(redactSecretsForLog("plain text, not json")).toBe("plain text, not json");
153
221
  });
222
+
223
+ test("does not redact benign keys that merely contain a sensitive substring", () => {
224
+ // /auth/ must not eat author/authorized_at, /token/ must not eat
225
+ // token_count, /cred/ must not eat credits — these are legitimate data
226
+ // fields in exec_sql / DBLab / bot results.
227
+ const out = redactSecretsForLog(JSON.stringify({
228
+ author: "alice",
229
+ authored_by: "bob",
230
+ authorized_at: "2026-01-01",
231
+ author_id: 12,
232
+ author_name: "Ada",
233
+ token_count: 42,
234
+ token_type: "bearer",
235
+ tokens: ["word"],
236
+ total_tokens: 7,
237
+ credits: 3,
238
+ credited_at: "2026-01-02",
239
+ password: "s3cr3t",
240
+ auth_key: "k1",
241
+ }));
242
+ expect(out).toContain('"author":"alice"');
243
+ expect(out).toContain('"authored_by":"bob"');
244
+ expect(out).toContain('"authorized_at":"2026-01-01"');
245
+ expect(out).toContain('"author_id":12');
246
+ expect(out).toContain('"author_name":"Ada"');
247
+ expect(out).toContain('"token_count":42');
248
+ expect(out).toContain('"token_type":"bearer"');
249
+ expect(out).toContain('"tokens":["word"]');
250
+ expect(out).toContain('"total_tokens":7');
251
+ expect(out).toContain('"credits":3');
252
+ expect(out).toContain('"credited_at":"2026-01-02"');
253
+ // real credentials still go
254
+ expect(out).not.toContain("s3cr3t");
255
+ expect(out).not.toContain('"k1"');
256
+ });
257
+
258
+ test("scrubs credential patterns from non-JSON text (error-path bodies)", () => {
259
+ // A non-JSON body flows into thrown Errors (parse-failure paths), so the
260
+ // text fallback must still scrub key=value credentials and URL userinfo.
261
+ const out = redactSecretsForLog(
262
+ 'clone failed: connStr=postgresql://joe:pw-abc@host:6002/db password=hunter2 "secret": "s3cr3t" user=joe'
263
+ );
264
+ expect(out).not.toContain("pw-abc");
265
+ expect(out).not.toContain("hunter2");
266
+ expect(out).not.toContain("s3cr3t");
267
+ expect(out).toContain("[REDACTED]");
268
+ expect(out).toContain("user=joe");
269
+ });
154
270
  });
package/lib/dblab.ts DELETED
@@ -1,449 +0,0 @@
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`). The destructive verbs — the HTTP DELETEs: clone
22
- * destroy (`DELETE /clone/<id>`), branch delete (`DELETE /branch/<name>`),
23
- * snapshot destroy (`DELETE /snapshot/<id>`) — are gated server-side by the
24
- * `joe:admin` scope (user-approved tightening, !612); clone reset is a POST and
25
- * is NOT gated. Read/list/create stay at org-token + project-allowlist level.
26
- * A missing scope on a DELETE surfaces here as a `PT403` → HTTP 403.
27
- */
28
-
29
- import { formatHttpError, maskSecret, normalizeBaseUrl, describeFetchError, redactSecretsForLog } from "./util";
30
- import { listProjects, type ProjectListItem } from "./joe";
31
-
32
- // ---------------------------------------------------------------------------
33
- // Types
34
- // ---------------------------------------------------------------------------
35
-
36
- /** A row of the `v1.dblab_instances` view (only the fields the resolver needs). */
37
- export interface DblabInstanceRow {
38
- /** The DBLab instance id — this is the `instance_id` passed to `dblab_api_call`. */
39
- id: number | string;
40
- project_id?: number | null;
41
- project_name?: string | null;
42
- project_alias?: string | null;
43
- project_label_or_name?: string | null;
44
- org_id?: number | null;
45
- }
46
-
47
- interface DblabCommon {
48
- apiKey: string;
49
- apiBaseUrl: string;
50
- /** Resolved DBLab instance id (see `resolveDblabInstanceId`). */
51
- instanceId: string;
52
- debug?: boolean;
53
- }
54
-
55
- // ---------------------------------------------------------------------------
56
- // Project → DBLab instance resolution
57
- //
58
- // Resolution rides the org-level projects listing rpc (`v1.projects_list`,
59
- // S.1): its rows carry each project's `dblab_instance_id` (DB.4), and the rpc
60
- // authenticates with the CLI's opaque org `access-token` header. The previous
61
- // interim resolver read the `v1.dblab_instances` PostgREST view, which is
62
- // JWT-scoped — permanently empty/403 for a token-only caller — so it never
63
- // worked outside mocks and was dropped at wiring (dedupe note resolved).
64
- // ---------------------------------------------------------------------------
65
-
66
- /** A bare numeric `--project` value is a project id; anything else is an alias/name. */
67
- export function isNumericProjectRef(ref: string): boolean {
68
- return /^[0-9]+$/.test(ref.trim());
69
- }
70
-
71
- export interface ResolveDblabInstanceParams {
72
- apiKey: string;
73
- apiBaseUrl: string;
74
- /** `--project <id|alias>` — a numeric project id, or a project alias/name. */
75
- project: string;
76
- /** Optional org scope (narrows the projects listing). */
77
- orgId?: number;
78
- debug?: boolean;
79
- }
80
-
81
- /**
82
- * Resolve `--project <id|alias>` to the project's single DBLab `instance_id`.
83
- *
84
- * Lists the org's projects via `v1.projects_list` (the same call behind
85
- * `pgai projects`): a numeric ref matches `project_id`; anything else matches
86
- * `alias` / `name` / `label` (case-insensitive). Each project has at most one
87
- * active DBLab instance (`dblab_instance_id`). The returned id is a string so
88
- * a 64-bit id survives without JS number-precision loss.
89
- */
90
- export async function resolveDblabInstanceId(params: ResolveDblabInstanceParams): Promise<string> {
91
- const { apiKey, apiBaseUrl, orgId, debug } = params;
92
- if (!apiKey) {
93
- throw new Error("API key is required");
94
- }
95
- const ref = String(params.project ?? "").trim();
96
- if (!ref) {
97
- throw new Error("project is required (--project <id|alias>)");
98
- }
99
-
100
- let projects: ProjectListItem[];
101
- try {
102
- projects = await listProjects({ apiKey, apiBaseUrl, orgId, debug });
103
- } catch (err) {
104
- const message = err instanceof Error ? err.message : String(err);
105
- throw new Error(`Failed to resolve project's DBLab instance: ${message}`);
106
- }
107
-
108
- const numeric = isNumericProjectRef(ref);
109
- const needle = ref.toLowerCase();
110
- const match = projects.find((p) => {
111
- if (numeric) {
112
- return String(p.project_id) === ref;
113
- }
114
- return (
115
- (p.alias !== null && p.alias.toLowerCase() === needle) ||
116
- (p.name !== null && p.name.toLowerCase() === needle) ||
117
- (p.label !== null && p.label.toLowerCase() === needle)
118
- );
119
- });
120
-
121
- if (!match) {
122
- throw new Error(
123
- `No DBLab instance found for project '${ref}'. Run 'pgai projects' to see available projects.`
124
- );
125
- }
126
- if (match.dblab_instance_id == null) {
127
- throw new Error(
128
- `Project '${ref}' has no active DBLab instance. Register a DBLab instance for it in the Console first.`
129
- );
130
- }
131
- return String(match.dblab_instance_id);
132
- }
133
-
134
- // ---------------------------------------------------------------------------
135
- // Low-level proxy caller — POST /rpc/dblab_api_call
136
- // ---------------------------------------------------------------------------
137
-
138
- interface DblabApiCallParams {
139
- apiKey: string;
140
- apiBaseUrl: string;
141
- instanceId: string;
142
- /** Leading-slash DBLab engine path, e.g. `/clone`, `/branches`, `/snapshots`. */
143
- action: string;
144
- /** Lowercase HTTP verb forwarded to the DBLab engine: get/post/patch/delete. */
145
- method: string;
146
- /** Optional request body (mutations only), forwarded as a nested JSON object. */
147
- data?: Record<string, unknown>;
148
- operation: string;
149
- debug?: boolean;
150
- }
151
-
152
- /**
153
- * Proxy a single call to the DBLab engine via `v1.dblab_api_call`, mirroring the
154
- * Console's `request('/rpc/dblab_api_call', { body: {instance_id, action, method,
155
- * data} })`. Returns the parsed JSON reply, or throws a formatted HTTP error.
156
- */
157
- async function callDblabApi<T>(params: DblabApiCallParams): Promise<T> {
158
- const { apiKey, apiBaseUrl, instanceId, action, method, data, operation, debug } = params;
159
- if (!apiKey) {
160
- throw new Error("API key is required");
161
- }
162
- if (!instanceId) {
163
- throw new Error("instanceId is required");
164
- }
165
-
166
- const base = normalizeBaseUrl(apiBaseUrl);
167
- const url = new URL(`${base}/rpc/dblab_api_call`);
168
-
169
- const bodyObj: Record<string, unknown> = {
170
- instance_id: instanceId,
171
- action,
172
- method,
173
- };
174
- if (data !== undefined) {
175
- bodyObj.data = data;
176
- }
177
- const body = JSON.stringify(bodyObj);
178
-
179
- const headers: Record<string, string> = {
180
- "access-token": apiKey,
181
- "Content-Type": "application/json",
182
- "Connection": "close",
183
- };
184
-
185
- if (debug) {
186
- const debugHeaders = { ...headers, "access-token": maskSecret(apiKey) };
187
- console.error(`Debug: POST URL: ${url.toString()}`);
188
- console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
189
- // Redact credential fields (clone create embeds --db-password) — the MCP
190
- // caller controls the debug flag, so the raw body must never hit the log.
191
- console.error(`Debug: Request body: ${redactSecretsForLog(body)}`);
192
- }
193
-
194
- let response: Response;
195
- try {
196
- response = await fetch(url.toString(), { method: "POST", headers, body });
197
- } catch (err) {
198
- // Transport failure (connection refused, DNS, TLS, bad host/port) — surface
199
- // the real cause + URL rather than undici's opaque "fetch failed".
200
- throw new Error(describeFetchError(operation, base, err));
201
- }
202
- const text = await response.text();
203
-
204
- if (debug) {
205
- console.error(`Debug: Response status: ${response.status}`);
206
- // Clone create/status replies carry the live clone's db.password/connStr.
207
- console.error(`Debug: Response body: ${redactSecretsForLog(text)}`);
208
- }
209
-
210
- if (!response.ok) {
211
- // PostgREST maps custom `PTxyz` sqlstates to HTTP status `xyz`, so a missing
212
- // `joe:admin` scope on a destructive verb surfaces here as HTTP 403. The
213
- // RPC's user-facing message rides in the HTTP reason phrase (statusText),
214
- // not the JSON body — pass it through.
215
- throw new Error(formatHttpError(operation, response.status, text, response.statusText));
216
- }
217
-
218
- // Some DBLab actions (reset/destroy) reply with an empty body on success.
219
- if (text.trim() === "") {
220
- return null as unknown as T;
221
- }
222
- try {
223
- return JSON.parse(text) as T;
224
- } catch {
225
- throw new Error(`${operation}: failed to parse response: ${text}`);
226
- }
227
- }
228
-
229
- // ===========================================================================
230
- // Clones — create / list / status / reset / destroy
231
- // ===========================================================================
232
-
233
- export interface CreateCloneParams extends DblabCommon {
234
- /** Optional caller-chosen clone id (DBLab generates one when omitted). */
235
- cloneId?: string;
236
- /** Branch to clone from. */
237
- branch?: string;
238
- /** Snapshot id to clone from. */
239
- snapshotId?: string;
240
- /** Clone DB user (paired with `dbPassword`). */
241
- dbUser?: string;
242
- /** Clone DB password (paired with `dbUser`). */
243
- dbPassword?: string;
244
- /** Protect the clone from auto-deletion. */
245
- isProtected?: boolean;
246
- }
247
-
248
- /** Create a thin clone — `/clone` POST (Console `createClone`). */
249
- export async function createClone<T = unknown>(params: CreateCloneParams): Promise<T> {
250
- const { apiKey, apiBaseUrl, instanceId, cloneId, branch, snapshotId, dbUser, dbPassword, isProtected, debug } = params;
251
- const data: Record<string, unknown> = { protected: Boolean(isProtected) };
252
- if (cloneId) data.id = cloneId;
253
- if (branch) data.branch = branch;
254
- if (snapshotId) data.snapshot = { id: snapshotId };
255
- if (dbUser && dbPassword) data.db = { username: dbUser, password: dbPassword };
256
- return callDblabApi<T>({
257
- apiKey, apiBaseUrl, instanceId,
258
- action: "/clone", method: "post", data,
259
- operation: "Failed to create clone", debug,
260
- });
261
- }
262
-
263
- /** List clones — `/clones` GET. */
264
- export async function listClones<T = unknown>(params: DblabCommon): Promise<T> {
265
- const { apiKey, apiBaseUrl, instanceId, debug } = params;
266
- return callDblabApi<T>({
267
- apiKey, apiBaseUrl, instanceId,
268
- action: "/clones", method: "get",
269
- operation: "Failed to list clones", debug,
270
- });
271
- }
272
-
273
- export interface CloneIdParams extends DblabCommon {
274
- cloneId: string;
275
- }
276
-
277
- /** Get a clone's status — `/clone/<id>` GET (Console `getClone`). */
278
- export async function getClone<T = unknown>(params: CloneIdParams): Promise<T> {
279
- const { apiKey, apiBaseUrl, instanceId, cloneId, debug } = params;
280
- if (!cloneId) throw new Error("cloneId is required");
281
- return callDblabApi<T>({
282
- apiKey, apiBaseUrl, instanceId,
283
- action: `/clone/${encodeURIComponent(cloneId)}`, method: "get",
284
- operation: "Failed to get clone", debug,
285
- });
286
- }
287
-
288
- export interface ResetCloneParams extends CloneIdParams {
289
- /** Snapshot to reset to; when omitted, resets to the latest snapshot. */
290
- snapshotId?: string;
291
- /** Reset to the latest snapshot (defaults true when no `snapshotId` is given). */
292
- latest?: boolean;
293
- }
294
-
295
- /** Reset a clone to a pristine snapshot — `/clone/<id>/reset` POST (Console `resetClone`). */
296
- export async function resetClone<T = unknown>(params: ResetCloneParams): Promise<T> {
297
- const { apiKey, apiBaseUrl, instanceId, cloneId, snapshotId, latest, debug } = params;
298
- if (!cloneId) throw new Error("cloneId is required");
299
- const data: Record<string, unknown> = { latest: latest ?? !snapshotId };
300
- if (snapshotId) data.snapshotID = snapshotId;
301
- return callDblabApi<T>({
302
- apiKey, apiBaseUrl, instanceId,
303
- action: `/clone/${encodeURIComponent(cloneId)}/reset`, method: "post", data,
304
- operation: "Failed to reset clone", debug,
305
- });
306
- }
307
-
308
- /** Destroy a clone — `/clone/<id>` DELETE (Console `destroyClone`). */
309
- export async function destroyClone<T = unknown>(params: CloneIdParams): Promise<T> {
310
- const { apiKey, apiBaseUrl, instanceId, cloneId, debug } = params;
311
- if (!cloneId) throw new Error("cloneId is required");
312
- return callDblabApi<T>({
313
- apiKey, apiBaseUrl, instanceId,
314
- action: `/clone/${encodeURIComponent(cloneId)}`, method: "delete",
315
- operation: "Failed to destroy clone", debug,
316
- });
317
- }
318
-
319
- // ===========================================================================
320
- // Branches — list / create / delete / log
321
- // ===========================================================================
322
-
323
- /** List branches — `/branches` GET (Console `getBranches`). */
324
- export async function listBranches<T = unknown>(params: DblabCommon): Promise<T> {
325
- const { apiKey, apiBaseUrl, instanceId, debug } = params;
326
- return callDblabApi<T>({
327
- apiKey, apiBaseUrl, instanceId,
328
- action: "/branches", method: "get",
329
- operation: "Failed to list branches", debug,
330
- });
331
- }
332
-
333
- export interface CreateBranchParams extends DblabCommon {
334
- branchName: string;
335
- /** Parent branch to fork from. */
336
- baseBranch?: string;
337
- /** Snapshot id to base the branch on. */
338
- snapshotId?: string;
339
- }
340
-
341
- /** Create a branch — `/branch` POST (Console `createBranch`). */
342
- export async function createBranch<T = unknown>(params: CreateBranchParams): Promise<T> {
343
- const { apiKey, apiBaseUrl, instanceId, branchName, baseBranch, snapshotId, debug } = params;
344
- if (!branchName) throw new Error("branchName is required");
345
- const data: Record<string, unknown> = { branchName };
346
- if (baseBranch) data.baseBranch = baseBranch;
347
- if (snapshotId) data.snapshotID = snapshotId;
348
- return callDblabApi<T>({
349
- apiKey, apiBaseUrl, instanceId,
350
- action: "/branch", method: "post", data,
351
- operation: "Failed to create branch", debug,
352
- });
353
- }
354
-
355
- export interface BranchNameParams extends DblabCommon {
356
- branchName: string;
357
- }
358
-
359
- /** Delete a branch — `/branch/<name>` DELETE (Console `deleteBranch`). */
360
- export async function deleteBranch<T = unknown>(params: BranchNameParams): Promise<T> {
361
- const { apiKey, apiBaseUrl, instanceId, branchName, debug } = params;
362
- if (!branchName) throw new Error("branchName is required");
363
- return callDblabApi<T>({
364
- apiKey, apiBaseUrl, instanceId,
365
- action: `/branch/${encodeURIComponent(branchName)}`, method: "delete",
366
- operation: "Failed to delete branch", debug,
367
- });
368
- }
369
-
370
- /** List a branch's snapshot log — `/branch/<name>/log` GET (Console `getSnapshotList`). */
371
- export async function branchLog<T = unknown>(params: BranchNameParams): Promise<T> {
372
- const { apiKey, apiBaseUrl, instanceId, branchName, debug } = params;
373
- if (!branchName) throw new Error("branchName is required");
374
- return callDblabApi<T>({
375
- apiKey, apiBaseUrl, instanceId,
376
- action: `/branch/${encodeURIComponent(branchName)}/log`, method: "get",
377
- operation: "Failed to fetch branch log", debug,
378
- });
379
- }
380
-
381
- // ===========================================================================
382
- // Snapshots — list / create / destroy
383
- // ===========================================================================
384
-
385
- export interface ListSnapshotsParams extends DblabCommon {
386
- /** Filter snapshots by branch. */
387
- branchName?: string;
388
- /** Filter snapshots by dataset. */
389
- dataset?: string;
390
- }
391
-
392
- /** List snapshots — `/snapshots[?branch=&dataset=]` GET (Console `getSnapshots`). */
393
- export async function listSnapshots<T = unknown>(params: ListSnapshotsParams): Promise<T> {
394
- const { apiKey, apiBaseUrl, instanceId, branchName, dataset, debug } = params;
395
- const qs = new URLSearchParams();
396
- const branch = branchName?.trim();
397
- if (branch) qs.append("branch", branch);
398
- if (dataset) qs.append("dataset", dataset);
399
- const action = `/snapshots${qs.toString() ? `?${qs.toString()}` : ""}`;
400
- return callDblabApi<T>({
401
- apiKey, apiBaseUrl, instanceId,
402
- action, method: "get",
403
- operation: "Failed to list snapshots", debug,
404
- });
405
- }
406
-
407
- export interface CreateSnapshotParams extends DblabCommon {
408
- /** Clone to snapshot. */
409
- cloneId: string;
410
- /** Optional snapshot message. */
411
- message?: string;
412
- }
413
-
414
- /** Create a snapshot from a clone — `/branch/snapshot` POST (Console `createSnapshot`). */
415
- export async function createSnapshot<T = unknown>(params: CreateSnapshotParams): Promise<T> {
416
- const { apiKey, apiBaseUrl, instanceId, cloneId, message, debug } = params;
417
- if (!cloneId) throw new Error("cloneId is required");
418
- const data: Record<string, unknown> = { cloneID: cloneId };
419
- if (message) data.message = message;
420
- return callDblabApi<T>({
421
- apiKey, apiBaseUrl, instanceId,
422
- action: "/branch/snapshot", method: "post", data,
423
- operation: "Failed to create snapshot", debug,
424
- });
425
- }
426
-
427
- export interface DestroySnapshotParams extends DblabCommon {
428
- snapshotId: string;
429
- /** Force-delete even when dependent clones exist. */
430
- force?: boolean;
431
- }
432
-
433
- /** Destroy a snapshot — `/snapshot/<id>?force=<bool>` DELETE (Console `destroySnapshot`).
434
- *
435
- * The snapshot id is a MULTI-SEGMENT zfs path (`pool/branch/<b>/<clone>/r0@snap`),
436
- * which the DBLab engine routes as a wildcard path — it must be passed RAW,
437
- * exactly as the Console does. `encodeURIComponent` here turned `/`→`%2F` and
438
- * `@`→`%40`, which the engine rejects with 400 `invalid snapshot name given`
439
- * (verified live against DBLab CE 4.1.3). */
440
- export async function destroySnapshot<T = unknown>(params: DestroySnapshotParams): Promise<T> {
441
- const { apiKey, apiBaseUrl, instanceId, snapshotId, force, debug } = params;
442
- if (!snapshotId) throw new Error("snapshotId is required");
443
- const action = `/snapshot/${snapshotId}?force=${Boolean(force)}`;
444
- return callDblabApi<T>({
445
- apiKey, apiBaseUrl, instanceId,
446
- action, method: "delete",
447
- operation: "Failed to destroy snapshot", debug,
448
- });
449
- }