@dbx-tools/appkit 0.1.9

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.
@@ -0,0 +1,705 @@
1
+ /**
2
+ * Lakebase Postgres connection resolver.
3
+ *
4
+ * Reads the same env vars the `lakebase` plugin consumes (`PGHOST`,
5
+ * `PGDATABASE`, `PGPORT`, `PGSSLMODE`, `LAKEBASE_ENDPOINT`) and fills in
6
+ * whichever pieces are missing using the Lakebase Autoscaling REST API
7
+ * under `/api/2.0/postgres/` via the Databricks workspace client.
8
+ *
9
+ * `LAKEBASE_ENDPOINT` (and `config.endpoint`) accept anything
10
+ * {@link parseAddress} understands - canonical resource paths, Postgres
11
+ * URIs, bare hostnames, or bare project ids. The resolver layers
12
+ * whatever pieces fall out of parsing under explicit config / env
13
+ * values, then fills the remaining gaps via the API:
14
+ *
15
+ * 1. Reverse-lookup: when a host is known but no resource path is,
16
+ * scan projects -> branches -> endpoints for a matching
17
+ * `status.hosts.host` and recover the owning project/branch/endpoint.
18
+ * 2. Pick: when a project is known but child resources aren't, prefer
19
+ * the server-side default (`status.default`, `ENDPOINT_TYPE_READ_WRITE`,
20
+ * `databricks_postgres`) and fall back to "the only one" when a
21
+ * listing returns a single result.
22
+ * 3. Auto-create: when no projects exist at all, create one whose
23
+ * id defaults to `project.name()` slugified (override
24
+ * with `config.autoCreate: "my-id"` or disable with
25
+ * `config.autoCreate: false`). The create call is idempotent - an
26
+ * `ALREADY_EXISTS` response from a concurrent boot is treated as
27
+ * success. Then poll the default endpoint until it reports
28
+ * `current_state` `READY` or `IDLE`.
29
+ *
30
+ * {@link applyLakebaseToEnv} writes the resolved values back to
31
+ * `process.env` so the downstream `lakebase` plugin picks them up.
32
+ *
33
+ * @see https://docs.databricks.com/api/workspace/postgres
34
+ */
35
+
36
+ import { log, string } from "@dbx-tools/shared-core";
37
+ import { resolveConfigValue } from "./config";
38
+ import { project } from "@dbx-tools/core";
39
+ import { getWorkspaceClient } from "@databricks/appkit";
40
+ import { setTimeout as sleep } from "node:timers/promises";
41
+
42
+ import {
43
+ parseAddress,
44
+ parseResourcePath,
45
+ type LakebaseConnectionInputs,
46
+ type SslMode,
47
+ } from "./pgaddress";
48
+
49
+ const logger = log.logger("lakebase-resolver");
50
+ const API_BASE = "/api/2.0/postgres";
51
+ const DEFAULT_PORT = 5432;
52
+ const DEFAULT_SSL_MODE: SslMode = "require";
53
+ const DEFAULT_PG_VERSION = 17;
54
+ /** Lakebase project ids: `^[a-z][a-z0-9-]{0,61}[a-z0-9]$`. */
55
+ const PROJECT_ID_MAX_LEN = 63;
56
+ const OPERATION_TIMEOUT_MS = 5 * 60_000;
57
+ const OPERATION_POLL_MS = 2_000;
58
+ const ENDPOINT_READY_TIMEOUT_MS = 5 * 60_000;
59
+ const ENDPOINT_READY_POLL_MS = 2_000;
60
+
61
+ /**
62
+ * User-supplied Lakebase inputs (config or env) before any API resolution.
63
+ * Extends {@link LakebaseConnectionInputs} with resolver-only options.
64
+ * {@link resolveLakebaseConnection} fills gaps from the Lakebase API when
65
+ * it has enough context (typically a `project`).
66
+ */
67
+ export interface LakebaseResolverInputs extends LakebaseConnectionInputs {
68
+ /**
69
+ * What to do when no project exists in the workspace at all.
70
+ * - `undefined` (default): derive a project id from
71
+ * {@link project.name} (the host repo's `package.json`
72
+ * name) slugified to Lakebase id constraints, then create it.
73
+ * - `string`: create a new project with this exact id.
74
+ * - `false`: skip creation and throw with a clear error message.
75
+ */
76
+ autoCreate?: string | false;
77
+ }
78
+
79
+ /** Fully-resolved Lakebase Postgres connection. */
80
+ export interface LakebaseConnection extends LakebaseConnectionInputs {
81
+ port: number;
82
+ sslMode: SslMode;
83
+ }
84
+
85
+ /**
86
+ * Lakebase REST list responses follow the Google AIP convention:
87
+ * `{ <plural-resource>: T[], next_page_token?: string }`. We only read
88
+ * the first page; for auto-config's "pick something sensible" semantics the
89
+ * cap is fine.
90
+ */
91
+ interface ListResponse {
92
+ next_page_token?: string;
93
+ projects?: Project[];
94
+ branches?: Branch[];
95
+ endpoints?: Endpoint[];
96
+ databases?: Database[];
97
+ }
98
+
99
+ interface Project {
100
+ /** Full resource path: `projects/{p}`. */
101
+ name?: string;
102
+ }
103
+
104
+ interface Endpoint {
105
+ /** Full resource path: `projects/{p}/branches/{b}/endpoints/{e}`. */
106
+ name?: string;
107
+ uid?: string;
108
+ /**
109
+ * Server-side state. All connection info lives here - the spec block
110
+ * only carries the desired configuration, not the runtime hostnames.
111
+ */
112
+ status?: {
113
+ endpoint_type?: "ENDPOINT_TYPE_READ_WRITE" | "ENDPOINT_TYPE_READ_ONLY";
114
+ /** Resolved hostnames; `hosts.host` is the writable primary. */
115
+ hosts?: {
116
+ host?: string;
117
+ read_only_host?: string;
118
+ };
119
+ /** Compute state: `INITIALIZING`, `STARTING`, `READY`, `IDLE`, ... */
120
+ current_state?: string;
121
+ };
122
+ }
123
+
124
+ interface Branch {
125
+ /** Full resource path: `projects/{p}/branches/{b}`. */
126
+ name?: string;
127
+ status?: {
128
+ /** True for the project's default branch (e.g. `production`). */
129
+ default?: boolean;
130
+ current_state?: string;
131
+ };
132
+ }
133
+
134
+ interface Database {
135
+ /** Full resource path: `projects/{p}/branches/{b}/databases/{d}`. */
136
+ name?: string;
137
+ status?: {
138
+ /**
139
+ * Actual Postgres database name (used as `PGDATABASE`). May differ
140
+ * from the resource id - e.g. resource `databricks-postgres`
141
+ * surfaces as Postgres database `databricks_postgres`.
142
+ */
143
+ postgres_database?: string;
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Long-running operation envelope returned by mutating REST calls.
149
+ * `done: true` means terminal; check `error` before reading `response`.
150
+ */
151
+ interface Operation {
152
+ name?: string;
153
+ done?: boolean;
154
+ error?: unknown;
155
+ response?: unknown;
156
+ }
157
+
158
+ /**
159
+ * Pull resolver inputs from `process.env`, parse the address blob, and
160
+ * layer explicit config on top with this precedence:
161
+ *
162
+ * `config.<field>` > `config.resolveConfigValue` (`env`, then bundle
163
+ * validate JSON) > whatever {@link parseAddress} recovered from the
164
+ * `endpoint` / `LAKEBASE_ENDPOINT` blob.
165
+ *
166
+ * Set `config.endpoint` (or `LAKEBASE_ENDPOINT`) to any input
167
+ * {@link parseAddress} understands: canonical resource paths, Postgres
168
+ * URIs, bare hostnames, or bare project ids.
169
+ */
170
+ export async function readLakebaseInputs(
171
+ config?: LakebaseResolverInputs,
172
+ ): Promise<LakebaseResolverInputs> {
173
+ const rawAddress = config?.endpoint ?? (await resolveConfigValue("LAKEBASE_ENDPOINT"));
174
+ const parsed = parseAddress(rawAddress);
175
+ const portEnv = await resolveConfigValue("PGPORT");
176
+ return {
177
+ project: config?.project ?? parsed.project,
178
+ branch: config?.branch ?? parsed.branch,
179
+ // Only canonical endpoint resource paths survive here; URIs and
180
+ // bare hostnames set `host` instead and leave `endpoint` undefined
181
+ // until the REST resolver fills it in.
182
+ endpoint: parsed.endpoint,
183
+ database: config?.database ?? (await resolveConfigValue("PGDATABASE")) ?? parsed.database,
184
+ host: config?.host ?? (await resolveConfigValue("PGHOST")) ?? parsed.host,
185
+ port: config?.port ?? (portEnv ? Number.parseInt(portEnv, 10) : undefined) ?? parsed.port,
186
+ sslMode:
187
+ config?.sslMode ??
188
+ ((await resolveConfigValue("PGSSLMODE")) as SslMode | undefined) ??
189
+ parsed.sslMode,
190
+ autoCreate: config?.autoCreate,
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Resolve a fully-populated Postgres connection record from config + env.
196
+ *
197
+ * Returns immediately without network traffic when env already supplies
198
+ * `endpoint`, `host`, and `database`. Otherwise issues REST calls and
199
+ * may auto-create a project (see module docstring).
200
+ */
201
+ export async function resolveLakebaseConnection(
202
+ config?: LakebaseResolverInputs,
203
+ ): Promise<LakebaseConnection> {
204
+ const inputs = await readLakebaseInputs(config);
205
+ let { project, branch, endpoint, database, host } = inputs;
206
+ const port = inputs.port ?? DEFAULT_PORT;
207
+ const sslMode = inputs.sslMode ?? DEFAULT_SSL_MODE;
208
+
209
+ // Resource paths may carry redundant info; harvest project/branch
210
+ // from any canonical path that snuck in via PGDATABASE or similar.
211
+ if (endpoint && (!project || !branch)) {
212
+ const parsedEndpoint = parseAddress(endpoint);
213
+ project ??= parsedEndpoint.project;
214
+ branch ??= parsedEndpoint.branch;
215
+ }
216
+ if (database && (!project || !branch)) {
217
+ const parsedDatabase = parseAddress(database);
218
+ project ??= parsedDatabase.project;
219
+ branch ??= parsedDatabase.branch;
220
+ if (parsedDatabase.databaseResourceId) {
221
+ database = parsedDatabase.databaseResourceId;
222
+ }
223
+ }
224
+
225
+ // Already complete: skip every REST call.
226
+ if (endpoint && host && database) {
227
+ return { project, branch, endpoint, database, host, port, sslMode };
228
+ }
229
+
230
+ const ws = getWorkspaceClient({});
231
+
232
+ // Host known but no resource path: scan the workspace to find which
233
+ // endpoint owns this host so we can populate LAKEBASE_ENDPOINT.
234
+ if (!project && host) {
235
+ const found = await findEndpointByHost(ws, host);
236
+ if (found) {
237
+ project = found.project;
238
+ branch = found.branch;
239
+ endpoint ??= found.endpoint;
240
+ }
241
+ }
242
+
243
+ // No project anywhere in config/env/address: list, pick, or create.
244
+ if (!project) {
245
+ project = await pickOrCreateProject(ws, config?.autoCreate, logger);
246
+ }
247
+
248
+ if (!branch) {
249
+ branch = await pickBranch(ws, project, logger);
250
+ }
251
+
252
+ if (!endpoint) {
253
+ const ep = await pickEndpoint(ws, project, branch, logger);
254
+ endpoint = ep.name;
255
+ host ??= ep.host;
256
+ }
257
+
258
+ if (!host && endpoint) {
259
+ const parsedEndpoint = parseAddress(endpoint);
260
+ if (parsedEndpoint.project && parsedEndpoint.branch && parsedEndpoint.endpointId) {
261
+ const ep = await waitEndpointReady(
262
+ ws,
263
+ parsedEndpoint.project,
264
+ parsedEndpoint.branch,
265
+ parsedEndpoint.endpointId,
266
+ logger,
267
+ );
268
+ host = ep.status?.hosts?.host;
269
+ logger.debug("autopg: resolved host from endpoint", { host });
270
+ }
271
+ }
272
+
273
+ if (!database) {
274
+ database = await pickDatabase(ws, project, branch, logger);
275
+ }
276
+
277
+ return { project, branch, endpoint, database, host, port, sslMode };
278
+ }
279
+
280
+ /**
281
+ * Write resolved values back to `process.env` so the `lakebase` plugin
282
+ * (which reads env directly) picks them up during its own `setup()`.
283
+ * Existing env values are preserved; only missing keys are filled in,
284
+ * which keeps explicit overrides authoritative.
285
+ */
286
+ export function applyLakebaseToEnv(resolved: LakebaseConnection): void {
287
+ if (resolved.endpoint) process.env.LAKEBASE_ENDPOINT ??= resolved.endpoint;
288
+ if (resolved.host) process.env.PGHOST ??= resolved.host;
289
+ if (resolved.database) process.env.PGDATABASE ??= resolved.database;
290
+ process.env.PGPORT ??= String(resolved.port);
291
+ process.env.PGSSLMODE ??= resolved.sslMode;
292
+ }
293
+
294
+ type WorkspaceClient = ReturnType<typeof getWorkspaceClient>;
295
+
296
+ /** GET helper that always parses JSON and forwards through `apiClient`. */
297
+ async function getJson<T>(ws: WorkspaceClient, path: string): Promise<T> {
298
+ const res = await ws.apiClient.request({
299
+ path,
300
+ method: "GET",
301
+ headers: new Headers({ Accept: "application/json" }),
302
+ raw: false,
303
+ });
304
+ return res as T;
305
+ }
306
+
307
+ /** POST helper for create / mutate calls; returns the parsed JSON body. */
308
+ async function postJson<T>(
309
+ ws: WorkspaceClient,
310
+ path: string,
311
+ body: unknown,
312
+ query?: Record<string, string>,
313
+ ): Promise<T> {
314
+ const res = await ws.apiClient.request({
315
+ path,
316
+ method: "POST",
317
+ query,
318
+ headers: new Headers({
319
+ Accept: "application/json",
320
+ "Content-Type": "application/json",
321
+ }),
322
+ raw: false,
323
+ payload: body,
324
+ });
325
+ return res as T;
326
+ }
327
+
328
+ async function listProjects(ws: WorkspaceClient): Promise<Project[]> {
329
+ const res = await getJson<ListResponse>(ws, `${API_BASE}/projects`);
330
+ return res.projects ?? [];
331
+ }
332
+
333
+ async function listBranches(ws: WorkspaceClient, project: string): Promise<Branch[]> {
334
+ const res = await getJson<ListResponse>(ws, `${API_BASE}/projects/${project}/branches`);
335
+ return res.branches ?? [];
336
+ }
337
+
338
+ async function listEndpoints(
339
+ ws: WorkspaceClient,
340
+ project: string,
341
+ branch: string,
342
+ ): Promise<Endpoint[]> {
343
+ const res = await getJson<ListResponse>(
344
+ ws,
345
+ `${API_BASE}/projects/${project}/branches/${branch}/endpoints`,
346
+ );
347
+ return res.endpoints ?? [];
348
+ }
349
+
350
+ async function listDatabases(
351
+ ws: WorkspaceClient,
352
+ project: string,
353
+ branch: string,
354
+ ): Promise<Database[]> {
355
+ const res = await getJson<ListResponse>(
356
+ ws,
357
+ `${API_BASE}/projects/${project}/branches/${branch}/databases`,
358
+ );
359
+ return res.databases ?? [];
360
+ }
361
+
362
+ async function getEndpoint(
363
+ ws: WorkspaceClient,
364
+ project: string,
365
+ branch: string,
366
+ endpointId: string,
367
+ ): Promise<Endpoint> {
368
+ return getJson<Endpoint>(
369
+ ws,
370
+ `${API_BASE}/projects/${project}/branches/${branch}/endpoints/${endpointId}`,
371
+ );
372
+ }
373
+
374
+ /**
375
+ * Scan the workspace for an endpoint whose `status.hosts.host` matches
376
+ * the provided hostname. Used to recover the owning project/branch/
377
+ * endpoint resource path when the caller only supplied a Postgres URI.
378
+ *
379
+ * O(projects * branches * endpoints) - fine for typical workspaces
380
+ * (single digits per tier); pagination is intentionally not followed
381
+ * since this is a best-effort fallback.
382
+ */
383
+ async function findEndpointByHost(
384
+ ws: WorkspaceClient,
385
+ host: string,
386
+ ): Promise<{ project: string; branch: string; endpoint: string } | null> {
387
+ const projects = await listProjects(ws);
388
+ for (const p of projects) {
389
+ const projectId = parseResourcePath(p.name).project;
390
+ if (!projectId) continue;
391
+ const branches = await listBranches(ws, projectId);
392
+ for (const b of branches) {
393
+ const branchId = parseResourcePath(b.name).branch;
394
+ if (!branchId) continue;
395
+ const endpoints = await listEndpoints(ws, projectId, branchId);
396
+ const match = endpoints.find((e) => e.status?.hosts?.host === host);
397
+ if (match?.name) {
398
+ logger.debug("autopg: matched endpoint by host", {
399
+ host,
400
+ endpoint: match.name,
401
+ });
402
+ return {
403
+ project: projectId,
404
+ branch: branchId,
405
+ endpoint: match.name,
406
+ };
407
+ }
408
+ }
409
+ }
410
+ logger.debug("autopg: no endpoint matched host", { host });
411
+ return null;
412
+ }
413
+
414
+ /**
415
+ * Pick the project to use, or create one when the workspace is empty.
416
+ *
417
+ * Selection order:
418
+ * 1. Exactly one project listed -> use it.
419
+ * 2. Zero projects AND `autoCreate !== false` -> ensure a project with
420
+ * the resolved id exists, then return its id.
421
+ * 3. Zero projects AND `autoCreate === false` -> throw.
422
+ * 4. Multiple projects -> throw with the candidate list (set
423
+ * `config.project` or pin a project id in `LAKEBASE_ENDPOINT`).
424
+ */
425
+ async function pickOrCreateProject(
426
+ ws: WorkspaceClient,
427
+ autoCreate: string | false | undefined,
428
+ logger: log.Logger,
429
+ ): Promise<string> {
430
+ const projects = await listProjects(ws);
431
+ if (projects.length === 1) {
432
+ const id = parseResourcePath(projects[0]!.name).project;
433
+ if (id) {
434
+ logger.debug("autopg: using only project", { project: id });
435
+ return id;
436
+ }
437
+ }
438
+ if (projects.length === 0) {
439
+ if (autoCreate === false) {
440
+ throw new Error(
441
+ "autopg: no Lakebase projects found and `autoCreate: false`; create a project or set config.project / LAKEBASE_ENDPOINT",
442
+ );
443
+ }
444
+ const id = autoCreate ?? (await defaultProjectId());
445
+ return ensureProject(ws, id, logger);
446
+ }
447
+ const candidates = projects
448
+ .map((p) => parseResourcePath(p.name).project)
449
+ .filter((id): id is string => Boolean(id))
450
+ .join(", ");
451
+ throw new Error(
452
+ `autopg: multiple projects found; set config.project or pin a project id in LAKEBASE_ENDPOINT. Candidates: ${candidates}`,
453
+ );
454
+ }
455
+
456
+ /**
457
+ * Derive a Lakebase project id from the host repo's `package.json`
458
+ * name (via {@link project.name}) slugified to satisfy the
459
+ * Lakebase id constraint (`^[a-z][a-z0-9-]{0,61}[a-z0-9]$`).
460
+ *
461
+ * Throws when the slug ends up empty or starts with a digit, since the
462
+ * server would reject it anyway - callers should pass an explicit
463
+ * `autoCreate` id in that case.
464
+ */
465
+ async function defaultProjectId(): Promise<string> {
466
+ const name = project.name();
467
+ const slug = string.toSlugWithOptions({ maxLength: PROJECT_ID_MAX_LEN }, name);
468
+ if (!slug || !/^[a-z]/.test(slug)) {
469
+ throw new Error(
470
+ `autopg: could not derive a Lakebase project id from project name '${name}'; pass autoCreate explicitly`,
471
+ );
472
+ }
473
+ return slug;
474
+ }
475
+
476
+ /**
477
+ * Ensure a Lakebase project with `projectId` exists. Creates it and
478
+ * waits for the create operation to complete. An `ALREADY_EXISTS`
479
+ * response is treated as success - someone else (a concurrent boot,
480
+ * a sibling process) won the race and the project we wanted is now
481
+ * sitting there ready for downstream pickers.
482
+ *
483
+ * Project creation typically provisions a default `production` branch
484
+ * alongside; downstream pickers handle the rest.
485
+ */
486
+ async function ensureProject(
487
+ ws: WorkspaceClient,
488
+ projectId: string,
489
+ logger: log.Logger,
490
+ ): Promise<string> {
491
+ logger.warn("autopg: no projects found; creating", { project: projectId });
492
+ try {
493
+ const op = await postJson<Operation>(
494
+ ws,
495
+ `${API_BASE}/projects`,
496
+ { spec: { pg_version: DEFAULT_PG_VERSION } },
497
+ { project_id: projectId },
498
+ );
499
+ await waitForOperation(ws, op, logger);
500
+ logger.info("autopg: created project", { project: projectId });
501
+ } catch (err) {
502
+ if (!isAlreadyExistsError(err)) throw err;
503
+ logger.info("autopg: project already exists (race); proceeding", {
504
+ project: projectId,
505
+ });
506
+ }
507
+ return projectId;
508
+ }
509
+
510
+ /**
511
+ * Recognize the Databricks SDK's `ALREADY_EXISTS` failure modes so a
512
+ * lost race during `ensureProject` becomes a no-op instead of an error.
513
+ *
514
+ * The SDK throws `ApiError { errorCode, statusCode }` for structured
515
+ * server errors and `HttpError { code }` for transport-layer 4xx/5xx.
516
+ * Both surface a human message that often carries "already exists" so
517
+ * we use that as a final fallback for forward compatibility.
518
+ */
519
+ function isAlreadyExistsError(error: unknown): boolean {
520
+ if (!error || typeof error !== "object") return false;
521
+ const e = error as {
522
+ statusCode?: number;
523
+ code?: number;
524
+ errorCode?: string;
525
+ message?: string;
526
+ };
527
+ if (e.statusCode === 409 || e.code === 409) return true;
528
+ if (e.errorCode && /already.?exists/i.test(e.errorCode)) return true;
529
+ if (e.message && /already.?exists/i.test(e.message)) return true;
530
+ return false;
531
+ }
532
+
533
+ /**
534
+ * Poll a Lakebase long-running operation until `done: true`. Returns
535
+ * the final operation envelope (which may carry `response` or `error`).
536
+ *
537
+ * Throws when:
538
+ * - the response carries an `error` field;
539
+ * - `op.name` is missing (nothing to poll);
540
+ * - the timeout elapses before `done: true`.
541
+ */
542
+ async function waitForOperation(
543
+ ws: WorkspaceClient,
544
+ op: Operation,
545
+ logger: log.Logger,
546
+ ): Promise<Operation> {
547
+ if (op.done) {
548
+ if (op.error) {
549
+ throw new Error(`autopg: operation failed: ${JSON.stringify(op.error)}`);
550
+ }
551
+ return op;
552
+ }
553
+ const opName = op.name;
554
+ if (!opName) {
555
+ throw new Error("autopg: operation response has no name to poll");
556
+ }
557
+ const start = Date.now();
558
+ while (Date.now() - start < OPERATION_TIMEOUT_MS) {
559
+ await sleep(OPERATION_POLL_MS);
560
+ const current = await getJson<Operation>(ws, `${API_BASE}/${opName}`);
561
+ logger.debug("autopg: operation status", { op: opName, done: current.done });
562
+ if (current.done) {
563
+ if (current.error) {
564
+ throw new Error(`autopg: operation '${opName}' failed: ${JSON.stringify(current.error)}`);
565
+ }
566
+ return current;
567
+ }
568
+ }
569
+ throw new Error(
570
+ `autopg: operation '${opName}' did not complete within ${OPERATION_TIMEOUT_MS}ms`,
571
+ );
572
+ }
573
+
574
+ /**
575
+ * Poll `getEndpoint` until the compute reports a usable
576
+ * `status.current_state`. `READY` and `IDLE` are both acceptable -
577
+ * `IDLE` just means the compute has scaled to zero but a connection
578
+ * will wake it. Returns the final endpoint payload (with `hosts.host`).
579
+ */
580
+ async function waitEndpointReady(
581
+ ws: WorkspaceClient,
582
+ project: string,
583
+ branch: string,
584
+ endpointId: string,
585
+ logger: log.Logger,
586
+ ): Promise<Endpoint> {
587
+ const start = Date.now();
588
+ let last: Endpoint | null = null;
589
+ while (Date.now() - start < ENDPOINT_READY_TIMEOUT_MS) {
590
+ last = await getEndpoint(ws, project, branch, endpointId);
591
+ const state = last.status?.current_state;
592
+ if (state === "READY" || state === "IDLE") return last;
593
+ if (last.status?.hosts?.host && state !== "INITIALIZING") {
594
+ // Compute is in some other state (STARTING, etc.) but hostname is
595
+ // already published - good enough to connect; lakebase's OAuth
596
+ // token request will wake it.
597
+ return last;
598
+ }
599
+ logger.debug("autopg: waiting for endpoint", { endpointId, state });
600
+ await sleep(ENDPOINT_READY_POLL_MS);
601
+ }
602
+ throw new Error(
603
+ `autopg: endpoint '${endpointId}' under projects/${project}/branches/${branch} did not become ready within ${ENDPOINT_READY_TIMEOUT_MS}ms (last state: ${last?.status?.current_state ?? "unknown"})`,
604
+ );
605
+ }
606
+
607
+ /**
608
+ * Pick the default branch for a project. Prefers the branch flagged
609
+ * `status.default: true` (server-side default, typically `production`
610
+ * unless the project owner changed it). Falls back to the only branch
611
+ * when there's exactly one. Otherwise throws with the candidate list.
612
+ */
613
+ async function pickBranch(
614
+ ws: WorkspaceClient,
615
+ project: string,
616
+ logger: log.Logger,
617
+ ): Promise<string> {
618
+ const branches = await listBranches(ws, project);
619
+ if (branches.length === 0) {
620
+ throw new Error(`autopg: project '${project}' has no branches; cannot resolve a default`);
621
+ }
622
+ const flagged = branches.find((b) => b.status?.default === true);
623
+ const choice =
624
+ parseResourcePath(flagged?.name).branch ??
625
+ (branches.length === 1 ? parseResourcePath(branches[0]!.name).branch : undefined);
626
+ if (!choice) {
627
+ const candidates = branches
628
+ .map((b) => parseResourcePath(b.name).branch)
629
+ .filter((id): id is string => Boolean(id))
630
+ .join(", ");
631
+ throw new Error(
632
+ `autopg: project '${project}' has multiple branches and none marked default; set config.branch or include the branch in LAKEBASE_ENDPOINT. Candidates: ${candidates}`,
633
+ );
634
+ }
635
+ logger.debug("autopg: resolved branch", { project, branch: choice });
636
+ return choice;
637
+ }
638
+
639
+ /**
640
+ * Pick the primary endpoint for a (project, branch). Prefers
641
+ * `status.endpoint_type === ENDPOINT_TYPE_READ_WRITE`; falls back to
642
+ * the only endpoint when there's exactly one. Returns `{ name, host }`
643
+ * so the caller can populate both `LAKEBASE_ENDPOINT` and `PGHOST`
644
+ * from a single call.
645
+ */
646
+ async function pickEndpoint(
647
+ ws: WorkspaceClient,
648
+ project: string,
649
+ branch: string,
650
+ logger: log.Logger,
651
+ ): Promise<{ name: string; host?: string }> {
652
+ const endpoints = await listEndpoints(ws, project, branch);
653
+ if (endpoints.length === 0) {
654
+ throw new Error(
655
+ `autopg: branch 'projects/${project}/branches/${branch}' has no endpoints; cannot resolve LAKEBASE_ENDPOINT`,
656
+ );
657
+ }
658
+ const primary =
659
+ endpoints.find((e) => e.status?.endpoint_type === "ENDPOINT_TYPE_READ_WRITE") ??
660
+ (endpoints.length === 1 ? endpoints[0] : undefined);
661
+ if (!primary?.name) {
662
+ const names = endpoints.map((e) => e.name).filter(Boolean);
663
+ throw new Error(
664
+ `autopg: branch has no primary READ_WRITE endpoint; set LAKEBASE_ENDPOINT or config.endpoint. Candidates: ${names.join(", ")}`,
665
+ );
666
+ }
667
+ const host = primary.status?.hosts?.host;
668
+ logger.debug("autopg: resolved endpoint", { endpoint: primary.name, host });
669
+ return { name: primary.name, host };
670
+ }
671
+
672
+ /**
673
+ * Pick the default postgres database for a (project, branch). The
674
+ * Postgres database NAME (`status.postgres_database`) is what
675
+ * `PGDATABASE` needs - this differs from the resource id, which can
676
+ * use a different separator (e.g. resource `databricks-postgres`
677
+ * surfaces as database `databricks_postgres`). Prefers
678
+ * `databricks_postgres` (the Lakebase default), otherwise the only
679
+ * database.
680
+ */
681
+ async function pickDatabase(
682
+ ws: WorkspaceClient,
683
+ project: string,
684
+ branch: string,
685
+ logger: log.Logger,
686
+ ): Promise<string> {
687
+ const databases = await listDatabases(ws, project, branch);
688
+ if (databases.length === 0) {
689
+ throw new Error(
690
+ `autopg: branch 'projects/${project}/branches/${branch}' has no databases; cannot resolve PGDATABASE`,
691
+ );
692
+ }
693
+ const names = databases
694
+ .map((d) => d.status?.postgres_database)
695
+ .filter((n): n is string => Boolean(n));
696
+ const choice =
697
+ names.find((n) => n === "databricks_postgres") ?? (names.length === 1 ? names[0] : undefined);
698
+ if (!choice) {
699
+ throw new Error(
700
+ `autopg: multiple databases and no 'databricks_postgres'; set PGDATABASE or config.database. Candidates: ${names.join(", ")}`,
701
+ );
702
+ }
703
+ logger.debug("autopg: resolved database", { database: choice });
704
+ return choice;
705
+ }