postgresai 0.16.0-dev.3 → 0.16.0-dev.5

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
  // ---------------------------------------------------------------------------
@@ -444,11 +415,17 @@ export interface DestroySnapshotParams extends DblabCommon {
444
415
  force?: boolean;
445
416
  }
446
417
 
447
- /** Destroy a snapshot — `/snapshot/<id>?force=<bool>` DELETE (Console `destroySnapshot`). */
418
+ /** Destroy a snapshot — `/snapshot/<id>?force=<bool>` DELETE (Console `destroySnapshot`).
419
+ *
420
+ * The snapshot id is a MULTI-SEGMENT zfs path (`pool/branch/<b>/<clone>/r0@snap`),
421
+ * which the DBLab engine routes as a wildcard path — it must be passed RAW,
422
+ * exactly as the Console does. `encodeURIComponent` here turned `/`→`%2F` and
423
+ * `@`→`%40`, which the engine rejects with 400 `invalid snapshot name given`
424
+ * (verified live against DBLab CE 4.1.3). */
448
425
  export async function destroySnapshot<T = unknown>(params: DestroySnapshotParams): Promise<T> {
449
426
  const { apiKey, apiBaseUrl, instanceId, snapshotId, force, debug } = params;
450
427
  if (!snapshotId) throw new Error("snapshotId is required");
451
- const action = `/snapshot/${encodeURIComponent(snapshotId)}?force=${Boolean(force)}`;
428
+ const action = `/snapshot/${snapshotId}?force=${Boolean(force)}`;
452
429
  return callDblabApi<T>({
453
430
  apiKey, apiBaseUrl, instanceId,
454
431
  action, method: "delete",
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.5",
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(
@@ -295,6 +302,13 @@ describe("snapshot verbs → dblab_api_call", () => {
295
302
  expect(b.method).toBe("delete");
296
303
  });
297
304
 
305
+ test("destroySnapshot passes a multi-segment zfs snapshot id RAW (no percent-encoding)", async () => {
306
+ const cap = stubFetch(true);
307
+ await destroySnapshot({ ...common, snapshotId: "dblab_pool/branch/main/c-1/r0@20260707065703" });
308
+ // The engine 400s on %2F/%40 — the Console passes the id raw and so must we.
309
+ expect(bodyOf(cap.get()).action).toBe("/snapshot/dblab_pool/branch/main/c-1/r0@20260707065703?force=false");
310
+ });
311
+
298
312
  test("destroySnapshot defaults force=false", async () => {
299
313
  const cap = stubFetch(true);
300
314
  await destroySnapshot({ ...common, snapshotId: "s-1" });
@@ -330,12 +344,12 @@ describe("proxy error paths", () => {
330
344
 
331
345
  describe("project → instance → dblab_api_call", () => {
332
346
  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" });
347
+ const projects = [{ project_id: 12, alias: "main-db", dblab_instance_id: 7 }];
348
+ const { calls } = routeFetch(projects, { id: "c-1", status: "ready" });
335
349
  const instanceId = await resolveDblabInstanceId({ apiKey: "k", apiBaseUrl: API, project: "main-db" });
336
350
  await createClone({ apiKey: "k", apiBaseUrl: API, instanceId });
337
351
  expect(calls.length).toBe(2);
338
- expect(calls[0].url).toContain("/dblab_instances");
352
+ expect(calls[0].url).toContain("/rpc/projects_list");
339
353
  expect(calls[1].url).toBe(`${API}/rpc/dblab_api_call`);
340
354
  expect(bodyOf(calls[1]).instance_id).toBe("7");
341
355
  });
@@ -349,14 +363,14 @@ describe("MCP DBLab tools (handleToolCall)", () => {
349
363
  const rootOpts = { apiKey: "k", apiBaseUrl: API };
350
364
 
351
365
  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 });
366
+ const projects = [{ project_id: 12, alias: "main-db", dblab_instance_id: 7 }];
367
+ const { calls } = routeFetch(projects, { ok: true });
354
368
  const res = await handleToolCall(
355
369
  { params: { name: "clone_create", arguments: { project: "main-db" } } },
356
370
  rootOpts
357
371
  );
358
372
  expect(res.isError).toBeFalsy();
359
- expect(calls[0].url).toContain("/dblab_instances");
373
+ expect(calls[0].url).toContain("/rpc/projects_list");
360
374
  const b = bodyOf(calls[1]);
361
375
  expect(b.instance_id).toBe("7");
362
376
  expect(b.action).toBe("/clone");
@@ -364,8 +378,8 @@ describe("MCP DBLab tools (handleToolCall)", () => {
364
378
  });
365
379
 
366
380
  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 });
381
+ const projects = [{ project_id: 12, alias: "main-db", dblab_instance_id: 7 }];
382
+ const { calls } = routeFetch(projects, { ok: true });
369
383
  const res = await handleToolCall(
370
384
  { params: { name: "clone_destroy", arguments: { project: "main-db", clone_id: "c-1" } } },
371
385
  rootOpts
@@ -377,8 +391,8 @@ describe("MCP DBLab tools (handleToolCall)", () => {
377
391
  });
378
392
 
379
393
  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 });
394
+ const projects = [{ project_id: 12, alias: "main-db", dblab_instance_id: 7 }];
395
+ const { calls } = routeFetch(projects, { ok: true });
382
396
  await handleToolCall(
383
397
  { params: { name: "snapshot_create", arguments: { project: "main-db", clone_id: "c-1", message: "m" } } },
384
398
  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");