postgresai 0.16.0-dev.1 → 0.16.0-dev.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/dblab.ts ADDED
@@ -0,0 +1,449 @@
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
+ }
package/lib/init.ts CHANGED
@@ -924,14 +924,6 @@ export async function verifyInitSetup(params: {
924
924
  }
925
925
 
926
926
  // Check for helper functions
927
- const explainFnRes = await params.client.query(
928
- "select has_function_privilege($1, 'postgres_ai.explain_generic(text, text, text)', 'EXECUTE') as ok",
929
- [role]
930
- );
931
- if (!explainFnRes.rows?.[0]?.ok) {
932
- missingRequired.push("EXECUTE on postgres_ai.explain_generic(text, text, text)");
933
- }
934
-
935
927
  const tableDescribeFnRes = await params.client.query(
936
928
  "select has_function_privilege($1, 'postgres_ai.table_describe(text)', 'EXECUTE') as ok",
937
929
  [role]