postgresai 0.16.0-dev.3 → 0.16.0-dev.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/dblab.ts CHANGED
@@ -24,6 +24,7 @@
24
24
  */
25
25
 
26
26
  import { formatHttpError, maskSecret, normalizeBaseUrl } from "./util";
27
+ import { listProjects, type ProjectListItem } from "./joe";
27
28
 
28
29
  // ---------------------------------------------------------------------------
29
30
  // Types
@@ -51,14 +52,12 @@ interface DblabCommon {
51
52
  // ---------------------------------------------------------------------------
52
53
  // Project → DBLab instance resolution
53
54
  //
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.
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).
62
61
  // ---------------------------------------------------------------------------
63
62
 
64
63
  /** A bare numeric `--project` value is a project id; anything else is an alias/name. */
@@ -71,23 +70,21 @@ export interface ResolveDblabInstanceParams {
71
70
  apiBaseUrl: string;
72
71
  /** `--project <id|alias>` — a numeric project id, or a project alias/name. */
73
72
  project: string;
74
- /** Optional org scope (narrows the instance listing). */
73
+ /** Optional org scope (narrows the projects listing). */
75
74
  orgId?: number;
76
75
  debug?: boolean;
77
76
  }
78
77
 
79
78
  /**
80
- * Resolve `--project <id|alias>` to its single DBLab `instance_id`.
79
+ * Resolve `--project <id|alias>` to the project's single DBLab `instance_id`.
81
80
  *
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.
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.
88
86
  */
89
87
  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
88
  const { apiKey, apiBaseUrl, orgId, debug } = params;
92
89
  if (!apiKey) {
93
90
  throw new Error("API key is required");
@@ -97,55 +94,24 @@ export async function resolveDblabInstanceId(params: ResolveDblabInstanceParams)
97
94
  throw new Error("project is required (--project <id|alias>)");
98
95
  }
99
96
 
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[];
97
+ let projects: ProjectListItem[];
132
98
  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}`);
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}`);
137
103
  }
138
104
 
139
105
  const numeric = isNumericProjectRef(ref);
140
106
  const needle = ref.toLowerCase();
141
- const match = rows.find((r) => {
107
+ const match = projects.find((p) => {
142
108
  if (numeric) {
143
- return String(r.project_id ?? "") === ref;
109
+ return String(p.project_id) === ref;
144
110
  }
145
111
  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)
112
+ (p.alias !== null && p.alias.toLowerCase() === needle) ||
113
+ (p.name !== null && p.name.toLowerCase() === needle) ||
114
+ (p.label !== null && p.label.toLowerCase() === needle)
149
115
  );
150
116
  });
151
117
 
@@ -154,7 +120,12 @@ export async function resolveDblabInstanceId(params: ResolveDblabInstanceParams)
154
120
  `No DBLab instance found for project '${ref}'. Run 'pgai projects' to see available projects.`
155
121
  );
156
122
  }
157
- return String(match.id);
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);
158
129
  }
159
130
 
160
131
  // ---------------------------------------------------------------------------
package/lib/joe.ts CHANGED
@@ -119,6 +119,11 @@ export interface ProjectListItem {
119
119
  joe_ready: boolean;
120
120
  /** Whether the project's DBLab tunnel is connected. */
121
121
  tunnel: boolean;
122
+ /** The project's active JOE instance id (the M1a submit/status/result target). */
123
+ instance_id: number | null;
124
+ /** The project's active DBLAB instance id — the SPEC §8 DBLab-companion resolver
125
+ * passes this to `v1.dblab_api_call` (distinct from the Joe `instance_id`). */
126
+ dblab_instance_id: number | null;
122
127
  }
123
128
 
124
129
  // ---------------------------------------------------------------------------
@@ -285,6 +290,8 @@ interface RawProjectRow {
285
290
  joe_api_v2_enabled?: boolean;
286
291
  tunnel?: boolean;
287
292
  tunnel_ready?: boolean;
293
+ instance_id?: number | null;
294
+ dblab_instance_id?: number | null;
288
295
  }
289
296
 
290
297
  function normalizeProjectRow(row: RawProjectRow): ProjectListItem {
@@ -296,6 +303,8 @@ function normalizeProjectRow(row: RawProjectRow): ProjectListItem {
296
303
  label: row.label ?? null,
297
304
  joe_ready: Boolean(row.joe_ready ?? row.joe_api_v2_enabled ?? false),
298
305
  tunnel: Boolean(row.tunnel ?? row.tunnel_ready ?? false),
306
+ instance_id: row.instance_id == null ? null : Number(row.instance_id),
307
+ dblab_instance_id: row.dblab_instance_id == null ? null : Number(row.dblab_instance_id),
299
308
  };
300
309
  }
301
310
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresai",
3
- "version": "0.16.0-dev.3",
3
+ "version": "0.16.0-dev.4",
4
4
  "description": "postgres_ai CLI",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
@@ -48,14 +48,14 @@ interface Recorded {
48
48
  }
49
49
 
50
50
  /**
51
- * Fake Platform API: serves the `/dblab_instances` resolver listing and echoes
52
- * every `/rpc/dblab_api_call` proxy call so tests can assert the action/method/
53
- * payload the CLI produced.
51
+ * Fake Platform API: serves the `/rpc/projects_list` resolver listing (rows
52
+ * carry `dblab_instance_id`, S.1/DB.4) and echoes every `/rpc/dblab_api_call`
53
+ * proxy call so tests can assert the action/method/payload the CLI produced.
54
54
  */
55
- async function startFakeApi(instances?: unknown[]) {
55
+ async function startFakeApi(projects?: unknown[]) {
56
56
  const requests: Recorded[] = [];
57
- const rows = instances ?? [
58
- { id: 7, project_id: 12, project_name: "Main DB", project_alias: "main-db", project_label_or_name: "Main DB" },
57
+ const rows = projects ?? [
58
+ { project_id: 12, alias: "main-db", name: "Main DB", label: "Main DB", joe_ready: true, tunnel: false, instance_id: 1, dblab_instance_id: 7 },
59
59
  ];
60
60
 
61
61
  const server = Bun.serve({
@@ -68,7 +68,7 @@ async function startFakeApi(instances?: unknown[]) {
68
68
  try { bodyJson = bodyText ? JSON.parse(bodyText) : null; } catch { bodyJson = null; }
69
69
  requests.push({ method: req.method, pathname: url.pathname, search: url.search, bodyJson });
70
70
 
71
- if (req.method === "GET" && url.pathname.endsWith("/dblab_instances")) {
71
+ if (req.method === "POST" && url.pathname.endsWith("/rpc/projects_list")) {
72
72
  return new Response(JSON.stringify(rows), { status: 200, headers: { "Content-Type": "application/json" } });
73
73
  }
74
74
  if (req.method === "POST" && url.pathname.endsWith("/rpc/dblab_api_call")) {
@@ -88,7 +88,7 @@ async function startFakeApi(instances?: unknown[]) {
88
88
  baseUrl,
89
89
  requests,
90
90
  proxyCalls: () => requests.filter((r) => r.pathname.endsWith("/rpc/dblab_api_call")),
91
- resolverCalls: () => requests.filter((r) => r.pathname.endsWith("/dblab_instances")),
91
+ resolverCalls: () => requests.filter((r) => r.pathname.endsWith("/rpc/projects_list")),
92
92
  stop: () => server.stop(true),
93
93
  };
94
94
  }
@@ -318,8 +318,8 @@ describe("CLI DBLab companion command groups", () => {
318
318
  port: 0,
319
319
  async fetch(req) {
320
320
  const url = new URL(req.url);
321
- if (req.method === "GET" && url.pathname.endsWith("/dblab_instances")) {
322
- return new Response(JSON.stringify([{ id: 7, project_id: 12, project_alias: "main-db" }]), { status: 200 });
321
+ if (req.method === "POST" && url.pathname.endsWith("/rpc/projects_list")) {
322
+ return new Response(JSON.stringify([{ project_id: 12, alias: "main-db", dblab_instance_id: 7 }]), { status: 200 });
323
323
  }
324
324
  return new Response('{"message":"insufficient scope: joe:admin required"}', { status: 403 });
325
325
  },
@@ -40,13 +40,13 @@ function stubFetch(body: unknown, status = 200): { get: () => Captured | null }
40
40
  return { get: () => captured };
41
41
  }
42
42
 
43
- /** Route fetch by URL: `/dblab_instances` → instances, `/rpc/dblab_api_call` → reply. */
44
- function routeFetch(instances: unknown[], reply: unknown): { calls: Captured[] } {
43
+ /** Route fetch by URL: `/rpc/projects_list` → projects, `/rpc/dblab_api_call` → reply. */
44
+ function routeFetch(projects: unknown[], reply: unknown): { calls: Captured[] } {
45
45
  const calls: Captured[] = [];
46
46
  globalThis.fetch = mock((url: string, options: RequestInit) => {
47
47
  calls.push({ url, options });
48
- if (String(url).includes("/dblab_instances")) {
49
- return Promise.resolve(new Response(JSON.stringify(instances), { status: 200 }));
48
+ if (String(url).includes("/rpc/projects_list")) {
49
+ return Promise.resolve(new Response(JSON.stringify(projects), { status: 200 }));
50
50
  }
51
51
  return Promise.resolve(new Response(JSON.stringify(reply), { status: 200 }));
52
52
  }) as unknown as typeof fetch;
@@ -66,9 +66,10 @@ afterEach(() => {
66
66
  // ---------------------------------------------------------------------------
67
67
 
68
68
  describe("resolveDblabInstanceId", () => {
69
- const INSTANCES = [
70
- { id: 7, project_id: 12, project_name: "Main DB", project_alias: "main-db", project_label_or_name: "Main DB" },
71
- { id: 9, project_id: 34, project_name: "Analytics", project_alias: "analytics", project_label_or_name: "Analytics" },
69
+ const PROJECTS = [
70
+ { project_id: 12, alias: "main-db", name: "Main DB", label: "Main DB", joe_ready: true, tunnel: false, instance_id: 1, dblab_instance_id: 7 },
71
+ { project_id: 34, alias: "analytics", name: "Analytics", label: null, joe_ready: false, tunnel: false, instance_id: null, dblab_instance_id: 9 },
72
+ { project_id: 56, alias: "no-dblab", name: "No DBLab", label: null, joe_ready: false, tunnel: false, instance_id: null, dblab_instance_id: null },
72
73
  ];
73
74
 
74
75
  test("throws when apiKey is missing", async () => {
@@ -89,42 +90,48 @@ describe("resolveDblabInstanceId", () => {
89
90
  expect(isNumericProjectRef("main-db")).toBe(false);
90
91
  });
91
92
 
92
- test("resolves a numeric project id to its instance id (string)", async () => {
93
- const cap = stubFetch(INSTANCES);
93
+ test("resolves a numeric project id to its dblab instance id (string) via projects_list", async () => {
94
+ const cap = stubFetch(PROJECTS);
94
95
  const id = await resolveDblabInstanceId({ apiKey: "k", apiBaseUrl: API, project: "34" });
95
96
  expect(id).toBe("9");
96
97
  const c = cap.get()!;
97
- expect(c.url).toContain(`${API}/dblab_instances`);
98
- expect(c.url).toContain("select=");
99
- expect(c.options.method).toBe("GET");
98
+ expect(c.url).toBe(`${API}/rpc/projects_list`);
99
+ expect(c.options.method).toBe("POST");
100
100
  expect((c.options.headers as Record<string, string>)["access-token"]).toBe("k");
101
101
  });
102
102
 
103
- test("resolves an alias (case-insensitive) to its instance id", async () => {
104
- stubFetch(INSTANCES);
103
+ test("resolves an alias (case-insensitive) to its dblab instance id", async () => {
104
+ stubFetch(PROJECTS);
105
105
  const id = await resolveDblabInstanceId({ apiKey: "k", apiBaseUrl: API, project: "Main-DB" });
106
106
  expect(id).toBe("7");
107
107
  });
108
108
 
109
- test("resolves a project name to its instance id", async () => {
110
- stubFetch(INSTANCES);
109
+ test("resolves a project name to its dblab instance id", async () => {
110
+ stubFetch(PROJECTS);
111
111
  const id = await resolveDblabInstanceId({ apiKey: "k", apiBaseUrl: API, project: "analytics" });
112
112
  expect(id).toBe("9");
113
113
  });
114
114
 
115
- test("adds an org_id filter when orgId is provided", async () => {
116
- const cap = stubFetch(INSTANCES);
115
+ test("passes org_id in the rpc body when orgId is provided", async () => {
116
+ const cap = stubFetch(PROJECTS);
117
117
  await resolveDblabInstanceId({ apiKey: "k", apiBaseUrl: API, project: "12", orgId: 5 });
118
- expect(decodeURIComponent(cap.get()!.url)).toContain("org_id=eq.5");
118
+ expect(bodyOf(cap.get()).org_id).toBe(5);
119
119
  });
120
120
 
121
- test("throws a helpful error when no instance matches", async () => {
122
- stubFetch(INSTANCES);
121
+ test("throws a helpful error when no project matches", async () => {
122
+ stubFetch(PROJECTS);
123
123
  await expect(
124
124
  resolveDblabInstanceId({ apiKey: "k", apiBaseUrl: API, project: "nope" })
125
125
  ).rejects.toThrow(/No DBLab instance found for project 'nope'/);
126
126
  });
127
127
 
128
+ test("throws when the project exists but has no active DBLab instance", async () => {
129
+ stubFetch(PROJECTS);
130
+ await expect(
131
+ resolveDblabInstanceId({ apiKey: "k", apiBaseUrl: API, project: "no-dblab" })
132
+ ).rejects.toThrow(/has no active DBLab instance/);
133
+ });
134
+
128
135
  test("surfaces an HTTP error from the listing", async () => {
129
136
  stubFetch('{"message":"boom"}', 500);
130
137
  await expect(
@@ -330,12 +337,12 @@ describe("proxy error paths", () => {
330
337
 
331
338
  describe("project → instance → dblab_api_call", () => {
332
339
  test("a verb driven off a resolved instance sends both requests", async () => {
333
- const instances = [{ id: 7, project_id: 12, project_alias: "main-db" }];
334
- const { calls } = routeFetch(instances, { id: "c-1", status: "ready" });
340
+ const projects = [{ project_id: 12, alias: "main-db", dblab_instance_id: 7 }];
341
+ const { calls } = routeFetch(projects, { id: "c-1", status: "ready" });
335
342
  const instanceId = await resolveDblabInstanceId({ apiKey: "k", apiBaseUrl: API, project: "main-db" });
336
343
  await createClone({ apiKey: "k", apiBaseUrl: API, instanceId });
337
344
  expect(calls.length).toBe(2);
338
- expect(calls[0].url).toContain("/dblab_instances");
345
+ expect(calls[0].url).toContain("/rpc/projects_list");
339
346
  expect(calls[1].url).toBe(`${API}/rpc/dblab_api_call`);
340
347
  expect(bodyOf(calls[1]).instance_id).toBe("7");
341
348
  });
@@ -349,14 +356,14 @@ describe("MCP DBLab tools (handleToolCall)", () => {
349
356
  const rootOpts = { apiKey: "k", apiBaseUrl: API };
350
357
 
351
358
  test("clone_create resolves project then proxies /clone POST", async () => {
352
- const instances = [{ id: 7, project_id: 12, project_alias: "main-db" }];
353
- const { calls } = routeFetch(instances, { ok: true });
359
+ const projects = [{ project_id: 12, alias: "main-db", dblab_instance_id: 7 }];
360
+ const { calls } = routeFetch(projects, { ok: true });
354
361
  const res = await handleToolCall(
355
362
  { params: { name: "clone_create", arguments: { project: "main-db" } } },
356
363
  rootOpts
357
364
  );
358
365
  expect(res.isError).toBeFalsy();
359
- expect(calls[0].url).toContain("/dblab_instances");
366
+ expect(calls[0].url).toContain("/rpc/projects_list");
360
367
  const b = bodyOf(calls[1]);
361
368
  expect(b.instance_id).toBe("7");
362
369
  expect(b.action).toBe("/clone");
@@ -364,8 +371,8 @@ describe("MCP DBLab tools (handleToolCall)", () => {
364
371
  });
365
372
 
366
373
  test("clone_destroy proxies /clone/<id> DELETE", async () => {
367
- const instances = [{ id: 7, project_id: 12, project_alias: "main-db" }];
368
- const { calls } = routeFetch(instances, { ok: true });
374
+ const projects = [{ project_id: 12, alias: "main-db", dblab_instance_id: 7 }];
375
+ const { calls } = routeFetch(projects, { ok: true });
369
376
  const res = await handleToolCall(
370
377
  { params: { name: "clone_destroy", arguments: { project: "main-db", clone_id: "c-1" } } },
371
378
  rootOpts
@@ -377,8 +384,8 @@ describe("MCP DBLab tools (handleToolCall)", () => {
377
384
  });
378
385
 
379
386
  test("snapshot_create proxies /branch/snapshot POST with cloneID", async () => {
380
- const instances = [{ id: 7, project_id: 12, project_alias: "main-db" }];
381
- const { calls } = routeFetch(instances, { ok: true });
387
+ const projects = [{ project_id: 12, alias: "main-db", dblab_instance_id: 7 }];
388
+ const { calls } = routeFetch(projects, { ok: true });
382
389
  await handleToolCall(
383
390
  { params: { name: "snapshot_create", arguments: { project: "main-db", clone_id: "c-1", message: "m" } } },
384
391
  rootOpts
package/test/joe.test.ts CHANGED
@@ -176,14 +176,14 @@ describe("listProjects", () => {
176
176
  installFetch({
177
177
  projects_list: () =>
178
178
  json([
179
- { id: 12, alias: "main-db", name: "Main DB", label: "Main DB", joe_api_v2_enabled: true, tunnel_ready: true },
179
+ { id: 12, alias: "main-db", name: "Main DB", label: "Main DB", joe_api_v2_enabled: true, tunnel_ready: true, instance_id: 3, dblab_instance_id: 7 },
180
180
  { project_id: 13, alias: "dw", name: "Warehouse", joe_ready: false, tunnel: false },
181
181
  ]),
182
182
  });
183
183
  const projects = await listProjects({ apiKey: "k", apiBaseUrl: BASE });
184
184
  expect(projects).toEqual([
185
- { project_id: 12, alias: "main-db", name: "Main DB", label: "Main DB", joe_ready: true, tunnel: true },
186
- { project_id: 13, alias: "dw", name: "Warehouse", label: null, joe_ready: false, tunnel: false },
185
+ { project_id: 12, alias: "main-db", name: "Main DB", label: "Main DB", joe_ready: true, tunnel: true, instance_id: 3, dblab_instance_id: 7 },
186
+ { project_id: 13, alias: "dw", name: "Warehouse", label: null, joe_ready: false, tunnel: false, instance_id: null, dblab_instance_id: null },
187
187
  ]);
188
188
  });
189
189
 
@@ -498,7 +498,7 @@ describe("presentation helpers", () => {
498
498
 
499
499
  test("formatProjectsTable renders the brief's column header", () => {
500
500
  const table = formatProjectsTable([
501
- { project_id: 12, alias: "main-db", name: "Main DB", label: null, joe_ready: true, tunnel: true },
501
+ { project_id: 12, alias: "main-db", name: "Main DB", label: null, joe_ready: true, tunnel: true, instance_id: null, dblab_instance_id: 7 },
502
502
  ]);
503
503
  const [header, row] = table.split("\n");
504
504
  expect(header).toContain("PROJECT_ID");