postgresai 0.16.0-dev.1 → 0.16.0-dev.3

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,457 @@
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
+
28
+ // ---------------------------------------------------------------------------
29
+ // Types
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /** A row of the `v1.dblab_instances` view (only the fields the resolver needs). */
33
+ export interface DblabInstanceRow {
34
+ /** The DBLab instance id — this is the `instance_id` passed to `dblab_api_call`. */
35
+ id: number | string;
36
+ project_id?: number | null;
37
+ project_name?: string | null;
38
+ project_alias?: string | null;
39
+ project_label_or_name?: string | null;
40
+ org_id?: number | null;
41
+ }
42
+
43
+ interface DblabCommon {
44
+ apiKey: string;
45
+ apiBaseUrl: string;
46
+ /** Resolved DBLab instance id (see `resolveDblabInstanceId`). */
47
+ instanceId: string;
48
+ debug?: boolean;
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Project → DBLab instance resolution
53
+ //
54
+ // MINIMAL LOCAL RESOLVER (dedupe note for the merge into dev/joe-api-v2):
55
+ // The Joe-commands MR adds `resolveProjectId(--project)` (project id|alias →
56
+ // numeric project_id) and the projects-API (finding M-4) is slated to surface
57
+ // each project's single DBLab instance id. Neither has merged yet, so this
58
+ // module resolves `--project` → DBLab `instance_id` locally against the existing
59
+ // `v1.dblab_instances` view (the same list the Console reads). Once the shared
60
+ // resolver + per-project instance surfacing land, collapse this into that shared
61
+ // path so `--project` id/alias resolution lives in exactly one place.
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /** A bare numeric `--project` value is a project id; anything else is an alias/name. */
65
+ export function isNumericProjectRef(ref: string): boolean {
66
+ return /^[0-9]+$/.test(ref.trim());
67
+ }
68
+
69
+ export interface ResolveDblabInstanceParams {
70
+ apiKey: string;
71
+ apiBaseUrl: string;
72
+ /** `--project <id|alias>` — a numeric project id, or a project alias/name. */
73
+ project: string;
74
+ /** Optional org scope (narrows the instance listing). */
75
+ orgId?: number;
76
+ debug?: boolean;
77
+ }
78
+
79
+ /**
80
+ * Resolve `--project <id|alias>` to its single DBLab `instance_id`.
81
+ *
82
+ * Reuses the existing `v1.dblab_instances` listing (the same one the Console
83
+ * reads): a numeric ref matches on `project_id`; an alias/name matches
84
+ * `project_alias` / `project_name` / `project_label_or_name` (case-insensitive).
85
+ * There is exactly one DBLab instance per project, so the first match is the
86
+ * instance. The returned id is a string so a 64-bit id survives without JS
87
+ * number-precision loss.
88
+ */
89
+ export async function resolveDblabInstanceId(params: ResolveDblabInstanceParams): Promise<string> {
90
+ // TODO(joe-v2): unify --project resolution once projects_list (S.1) returns the dblab instance id
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
+ const base = normalizeBaseUrl(apiBaseUrl);
101
+ const url = new URL(`${base}/dblab_instances`);
102
+ url.searchParams.set("select", "id,project_id,project_name,project_alias,project_label_or_name,org_id");
103
+ if (typeof orgId === "number") {
104
+ url.searchParams.set("org_id", `eq.${orgId}`);
105
+ }
106
+
107
+ const headers: Record<string, string> = {
108
+ "access-token": apiKey,
109
+ "Content-Type": "application/json",
110
+ "Connection": "close",
111
+ };
112
+
113
+ if (debug) {
114
+ const debugHeaders = { ...headers, "access-token": maskSecret(apiKey) };
115
+ console.error(`Debug: GET URL: ${url.toString()}`);
116
+ console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
117
+ }
118
+
119
+ const response = await fetch(url.toString(), { method: "GET", headers });
120
+ const text = await response.text();
121
+
122
+ if (debug) {
123
+ console.error(`Debug: Response status: ${response.status}`);
124
+ console.error(`Debug: Response body: ${text}`);
125
+ }
126
+
127
+ if (!response.ok) {
128
+ throw new Error(formatHttpError("Failed to resolve project's DBLab instance", response.status, text));
129
+ }
130
+
131
+ let rows: DblabInstanceRow[];
132
+ try {
133
+ const parsed = JSON.parse(text);
134
+ rows = Array.isArray(parsed) ? parsed : [];
135
+ } catch {
136
+ throw new Error(`Failed to parse DBLab instances response: ${text}`);
137
+ }
138
+
139
+ const numeric = isNumericProjectRef(ref);
140
+ const needle = ref.toLowerCase();
141
+ const match = rows.find((r) => {
142
+ if (numeric) {
143
+ return String(r.project_id ?? "") === ref;
144
+ }
145
+ return (
146
+ (r.project_alias != null && String(r.project_alias).toLowerCase() === needle) ||
147
+ (r.project_name != null && String(r.project_name).toLowerCase() === needle) ||
148
+ (r.project_label_or_name != null && String(r.project_label_or_name).toLowerCase() === needle)
149
+ );
150
+ });
151
+
152
+ if (!match) {
153
+ throw new Error(
154
+ `No DBLab instance found for project '${ref}'. Run 'pgai projects' to see available projects.`
155
+ );
156
+ }
157
+ return String(match.id);
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Low-level proxy caller — POST /rpc/dblab_api_call
162
+ // ---------------------------------------------------------------------------
163
+
164
+ interface DblabApiCallParams {
165
+ apiKey: string;
166
+ apiBaseUrl: string;
167
+ instanceId: string;
168
+ /** Leading-slash DBLab engine path, e.g. `/clone`, `/branches`, `/snapshots`. */
169
+ action: string;
170
+ /** Lowercase HTTP verb forwarded to the DBLab engine: get/post/patch/delete. */
171
+ method: string;
172
+ /** Optional request body (mutations only), forwarded as a nested JSON object. */
173
+ data?: Record<string, unknown>;
174
+ operation: string;
175
+ debug?: boolean;
176
+ }
177
+
178
+ /**
179
+ * Proxy a single call to the DBLab engine via `v1.dblab_api_call`, mirroring the
180
+ * Console's `request('/rpc/dblab_api_call', { body: {instance_id, action, method,
181
+ * data} })`. Returns the parsed JSON reply, or throws a formatted HTTP error.
182
+ */
183
+ async function callDblabApi<T>(params: DblabApiCallParams): Promise<T> {
184
+ const { apiKey, apiBaseUrl, instanceId, action, method, data, operation, debug } = params;
185
+ if (!apiKey) {
186
+ throw new Error("API key is required");
187
+ }
188
+ if (!instanceId) {
189
+ throw new Error("instanceId is required");
190
+ }
191
+
192
+ const base = normalizeBaseUrl(apiBaseUrl);
193
+ const url = new URL(`${base}/rpc/dblab_api_call`);
194
+
195
+ const bodyObj: Record<string, unknown> = {
196
+ instance_id: instanceId,
197
+ action,
198
+ method,
199
+ };
200
+ if (data !== undefined) {
201
+ bodyObj.data = data;
202
+ }
203
+ const body = JSON.stringify(bodyObj);
204
+
205
+ const headers: Record<string, string> = {
206
+ "access-token": apiKey,
207
+ "Content-Type": "application/json",
208
+ "Connection": "close",
209
+ };
210
+
211
+ if (debug) {
212
+ const debugHeaders = { ...headers, "access-token": maskSecret(apiKey) };
213
+ console.error(`Debug: POST URL: ${url.toString()}`);
214
+ console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
215
+ console.error(`Debug: Request body: ${body}`);
216
+ }
217
+
218
+ const response = await fetch(url.toString(), { method: "POST", headers, body });
219
+ const text = await response.text();
220
+
221
+ if (debug) {
222
+ console.error(`Debug: Response status: ${response.status}`);
223
+ console.error(`Debug: Response body: ${text}`);
224
+ }
225
+
226
+ if (!response.ok) {
227
+ // PostgREST maps custom `PTxyz` sqlstates to HTTP status `xyz`, so a missing
228
+ // `joe:admin` scope on a destructive verb surfaces here as HTTP 403.
229
+ throw new Error(formatHttpError(operation, response.status, text));
230
+ }
231
+
232
+ // Some DBLab actions (reset/destroy) reply with an empty body on success.
233
+ if (text.trim() === "") {
234
+ return null as unknown as T;
235
+ }
236
+ try {
237
+ return JSON.parse(text) as T;
238
+ } catch {
239
+ throw new Error(`${operation}: failed to parse response: ${text}`);
240
+ }
241
+ }
242
+
243
+ // ===========================================================================
244
+ // Clones — create / list / status / reset / destroy
245
+ // ===========================================================================
246
+
247
+ export interface CreateCloneParams extends DblabCommon {
248
+ /** Optional caller-chosen clone id (DBLab generates one when omitted). */
249
+ cloneId?: string;
250
+ /** Branch to clone from. */
251
+ branch?: string;
252
+ /** Snapshot id to clone from. */
253
+ snapshotId?: string;
254
+ /** Clone DB user (paired with `dbPassword`). */
255
+ dbUser?: string;
256
+ /** Clone DB password (paired with `dbUser`). */
257
+ dbPassword?: string;
258
+ /** Protect the clone from auto-deletion. */
259
+ isProtected?: boolean;
260
+ }
261
+
262
+ /** Create a thin clone — `/clone` POST (Console `createClone`). */
263
+ export async function createClone<T = unknown>(params: CreateCloneParams): Promise<T> {
264
+ const { apiKey, apiBaseUrl, instanceId, cloneId, branch, snapshotId, dbUser, dbPassword, isProtected, debug } = params;
265
+ const data: Record<string, unknown> = { protected: Boolean(isProtected) };
266
+ if (cloneId) data.id = cloneId;
267
+ if (branch) data.branch = branch;
268
+ if (snapshotId) data.snapshot = { id: snapshotId };
269
+ if (dbUser && dbPassword) data.db = { username: dbUser, password: dbPassword };
270
+ return callDblabApi<T>({
271
+ apiKey, apiBaseUrl, instanceId,
272
+ action: "/clone", method: "post", data,
273
+ operation: "Failed to create clone", debug,
274
+ });
275
+ }
276
+
277
+ /** List clones — `/clones` GET. */
278
+ export async function listClones<T = unknown>(params: DblabCommon): Promise<T> {
279
+ const { apiKey, apiBaseUrl, instanceId, debug } = params;
280
+ return callDblabApi<T>({
281
+ apiKey, apiBaseUrl, instanceId,
282
+ action: "/clones", method: "get",
283
+ operation: "Failed to list clones", debug,
284
+ });
285
+ }
286
+
287
+ export interface CloneIdParams extends DblabCommon {
288
+ cloneId: string;
289
+ }
290
+
291
+ /** Get a clone's status — `/clone/<id>` GET (Console `getClone`). */
292
+ export async function getClone<T = unknown>(params: CloneIdParams): Promise<T> {
293
+ const { apiKey, apiBaseUrl, instanceId, cloneId, debug } = params;
294
+ if (!cloneId) throw new Error("cloneId is required");
295
+ return callDblabApi<T>({
296
+ apiKey, apiBaseUrl, instanceId,
297
+ action: `/clone/${encodeURIComponent(cloneId)}`, method: "get",
298
+ operation: "Failed to get clone", debug,
299
+ });
300
+ }
301
+
302
+ export interface ResetCloneParams extends CloneIdParams {
303
+ /** Snapshot to reset to; when omitted, resets to the latest snapshot. */
304
+ snapshotId?: string;
305
+ /** Reset to the latest snapshot (defaults true when no `snapshotId` is given). */
306
+ latest?: boolean;
307
+ }
308
+
309
+ /** Reset a clone to a pristine snapshot — `/clone/<id>/reset` POST (Console `resetClone`). */
310
+ export async function resetClone<T = unknown>(params: ResetCloneParams): Promise<T> {
311
+ const { apiKey, apiBaseUrl, instanceId, cloneId, snapshotId, latest, debug } = params;
312
+ if (!cloneId) throw new Error("cloneId is required");
313
+ const data: Record<string, unknown> = { latest: latest ?? !snapshotId };
314
+ if (snapshotId) data.snapshotID = snapshotId;
315
+ return callDblabApi<T>({
316
+ apiKey, apiBaseUrl, instanceId,
317
+ action: `/clone/${encodeURIComponent(cloneId)}/reset`, method: "post", data,
318
+ operation: "Failed to reset clone", debug,
319
+ });
320
+ }
321
+
322
+ /** Destroy a clone — `/clone/<id>` DELETE (Console `destroyClone`). */
323
+ export async function destroyClone<T = unknown>(params: CloneIdParams): Promise<T> {
324
+ const { apiKey, apiBaseUrl, instanceId, cloneId, debug } = params;
325
+ if (!cloneId) throw new Error("cloneId is required");
326
+ return callDblabApi<T>({
327
+ apiKey, apiBaseUrl, instanceId,
328
+ action: `/clone/${encodeURIComponent(cloneId)}`, method: "delete",
329
+ operation: "Failed to destroy clone", debug,
330
+ });
331
+ }
332
+
333
+ // ===========================================================================
334
+ // Branches — list / create / delete / log
335
+ // ===========================================================================
336
+
337
+ /** List branches — `/branches` GET (Console `getBranches`). */
338
+ export async function listBranches<T = unknown>(params: DblabCommon): Promise<T> {
339
+ const { apiKey, apiBaseUrl, instanceId, debug } = params;
340
+ return callDblabApi<T>({
341
+ apiKey, apiBaseUrl, instanceId,
342
+ action: "/branches", method: "get",
343
+ operation: "Failed to list branches", debug,
344
+ });
345
+ }
346
+
347
+ export interface CreateBranchParams extends DblabCommon {
348
+ branchName: string;
349
+ /** Parent branch to fork from. */
350
+ baseBranch?: string;
351
+ /** Snapshot id to base the branch on. */
352
+ snapshotId?: string;
353
+ }
354
+
355
+ /** Create a branch — `/branch` POST (Console `createBranch`). */
356
+ export async function createBranch<T = unknown>(params: CreateBranchParams): Promise<T> {
357
+ const { apiKey, apiBaseUrl, instanceId, branchName, baseBranch, snapshotId, debug } = params;
358
+ if (!branchName) throw new Error("branchName is required");
359
+ const data: Record<string, unknown> = { branchName };
360
+ if (baseBranch) data.baseBranch = baseBranch;
361
+ if (snapshotId) data.snapshotID = snapshotId;
362
+ return callDblabApi<T>({
363
+ apiKey, apiBaseUrl, instanceId,
364
+ action: "/branch", method: "post", data,
365
+ operation: "Failed to create branch", debug,
366
+ });
367
+ }
368
+
369
+ export interface BranchNameParams extends DblabCommon {
370
+ branchName: string;
371
+ }
372
+
373
+ /** Delete a branch — `/branch/<name>` DELETE (Console `deleteBranch`). */
374
+ export async function deleteBranch<T = unknown>(params: BranchNameParams): Promise<T> {
375
+ const { apiKey, apiBaseUrl, instanceId, branchName, debug } = params;
376
+ if (!branchName) throw new Error("branchName is required");
377
+ return callDblabApi<T>({
378
+ apiKey, apiBaseUrl, instanceId,
379
+ action: `/branch/${encodeURIComponent(branchName)}`, method: "delete",
380
+ operation: "Failed to delete branch", debug,
381
+ });
382
+ }
383
+
384
+ /** List a branch's snapshot log — `/branch/<name>/log` GET (Console `getSnapshotList`). */
385
+ export async function branchLog<T = unknown>(params: BranchNameParams): Promise<T> {
386
+ const { apiKey, apiBaseUrl, instanceId, branchName, debug } = params;
387
+ if (!branchName) throw new Error("branchName is required");
388
+ return callDblabApi<T>({
389
+ apiKey, apiBaseUrl, instanceId,
390
+ action: `/branch/${encodeURIComponent(branchName)}/log`, method: "get",
391
+ operation: "Failed to fetch branch log", debug,
392
+ });
393
+ }
394
+
395
+ // ===========================================================================
396
+ // Snapshots — list / create / destroy
397
+ // ===========================================================================
398
+
399
+ export interface ListSnapshotsParams extends DblabCommon {
400
+ /** Filter snapshots by branch. */
401
+ branchName?: string;
402
+ /** Filter snapshots by dataset. */
403
+ dataset?: string;
404
+ }
405
+
406
+ /** List snapshots — `/snapshots[?branch=&dataset=]` GET (Console `getSnapshots`). */
407
+ export async function listSnapshots<T = unknown>(params: ListSnapshotsParams): Promise<T> {
408
+ const { apiKey, apiBaseUrl, instanceId, branchName, dataset, debug } = params;
409
+ const qs = new URLSearchParams();
410
+ const branch = branchName?.trim();
411
+ if (branch) qs.append("branch", branch);
412
+ if (dataset) qs.append("dataset", dataset);
413
+ const action = `/snapshots${qs.toString() ? `?${qs.toString()}` : ""}`;
414
+ return callDblabApi<T>({
415
+ apiKey, apiBaseUrl, instanceId,
416
+ action, method: "get",
417
+ operation: "Failed to list snapshots", debug,
418
+ });
419
+ }
420
+
421
+ export interface CreateSnapshotParams extends DblabCommon {
422
+ /** Clone to snapshot. */
423
+ cloneId: string;
424
+ /** Optional snapshot message. */
425
+ message?: string;
426
+ }
427
+
428
+ /** Create a snapshot from a clone — `/branch/snapshot` POST (Console `createSnapshot`). */
429
+ export async function createSnapshot<T = unknown>(params: CreateSnapshotParams): Promise<T> {
430
+ const { apiKey, apiBaseUrl, instanceId, cloneId, message, debug } = params;
431
+ if (!cloneId) throw new Error("cloneId is required");
432
+ const data: Record<string, unknown> = { cloneID: cloneId };
433
+ if (message) data.message = message;
434
+ return callDblabApi<T>({
435
+ apiKey, apiBaseUrl, instanceId,
436
+ action: "/branch/snapshot", method: "post", data,
437
+ operation: "Failed to create snapshot", debug,
438
+ });
439
+ }
440
+
441
+ export interface DestroySnapshotParams extends DblabCommon {
442
+ snapshotId: string;
443
+ /** Force-delete even when dependent clones exist. */
444
+ force?: boolean;
445
+ }
446
+
447
+ /** Destroy a snapshot — `/snapshot/<id>?force=<bool>` DELETE (Console `destroySnapshot`). */
448
+ export async function destroySnapshot<T = unknown>(params: DestroySnapshotParams): Promise<T> {
449
+ const { apiKey, apiBaseUrl, instanceId, snapshotId, force, debug } = params;
450
+ if (!snapshotId) throw new Error("snapshotId is required");
451
+ const action = `/snapshot/${encodeURIComponent(snapshotId)}?force=${Boolean(force)}`;
452
+ return callDblabApi<T>({
453
+ apiKey, apiBaseUrl, instanceId,
454
+ action, method: "delete",
455
+ operation: "Failed to destroy snapshot", debug,
456
+ });
457
+ }
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]