@dbx-tools/appkit 0.3.28 → 0.3.30

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.
@@ -35,15 +35,22 @@
35
35
  * @module
36
36
  */
37
37
 
38
- import { setTimeout as sleep } from "node:timers/promises";
39
- import { getWorkspaceClient } from "@databricks/appkit";
38
+ import {
39
+ ConfigurationError,
40
+ ExecutionError,
41
+ getWorkspaceClient,
42
+ ValidationError,
43
+ } from "@databricks/appkit";
40
44
  import { project } from "@dbx-tools/core";
41
- import { log, string } from "@dbx-tools/shared-core";
45
+ import { async, log, object, string } from "@dbx-tools/shared-core";
46
+ import { z } from "zod";
42
47
  import { resolveConfigValue } from "./config";
43
48
 
49
+ import { MAX_TCP_PORT, toContext } from "./databricks";
44
50
  import {
45
51
  parseAddress,
46
52
  parseResourcePath,
53
+ SSL_MODES,
47
54
  type LakebaseConnectionInputs,
48
55
  type SslMode,
49
56
  } from "./pgaddress";
@@ -59,6 +66,19 @@ const OPERATION_TIMEOUT_MS = 5 * 60_000;
59
66
  const OPERATION_POLL_MS = 2_000;
60
67
  const ENDPOINT_READY_TIMEOUT_MS = 5 * 60_000;
61
68
  const ENDPOINT_READY_POLL_MS = 2_000;
69
+ /** Ceiling on a single backoff step, so a long wait still polls regularly. */
70
+ const POLL_MAX_DELAY_MS = 15_000;
71
+ const POLL_BACKOFF_FACTOR = 1.5;
72
+ /** Fraction of each delay applied as +/- jitter, so concurrent boots desynchronize. */
73
+ const POLL_JITTER_RATIO = 0.2;
74
+ /** Endpoint `status.endpoint_type` for the writable primary. */
75
+ const READ_WRITE_ENDPOINT_TYPE = "ENDPOINT_TYPE_READ_WRITE";
76
+ /** Endpoint `status.current_state` values that will accept a connection. */
77
+ const CONNECTABLE_ENDPOINT_STATES = new Set(["READY", "IDLE"]);
78
+ /** Endpoint `status.current_state` before a hostname is meaningful. */
79
+ const INITIALIZING_ENDPOINT_STATE = "INITIALIZING";
80
+ /** Lakebase's own default Postgres database name. */
81
+ const DEFAULT_DATABASE = "databricks_postgres";
62
82
 
63
83
  /**
64
84
  * User-supplied Lakebase inputs (config or env) before any API resolution.
@@ -84,77 +104,146 @@ export interface LakebaseConnection extends LakebaseConnectionInputs {
84
104
  sslMode: SslMode;
85
105
  }
86
106
 
107
+ /**
108
+ * Every field of the Lakebase REST payloads is modelled as optional and
109
+ * validated at the boundary rather than asserted with a cast: the pickers below
110
+ * branch on `status.*`, and a shape change upstream should surface as one loud
111
+ * error here instead of an `undefined` three frames away. Enum-shaped fields
112
+ * stay `string` so a newly added state does not fail the whole response.
113
+ */
114
+ const projectSchema = z.object({
115
+ // Full resource path: `projects/{p}`.
116
+ name: z.string().optional(),
117
+ });
118
+ type Project = z.infer<typeof projectSchema>;
119
+
120
+ const endpointSchema = z.object({
121
+ // Full resource path: `projects/{p}/branches/{b}/endpoints/{e}`.
122
+ name: z.string().optional(),
123
+ uid: z.string().optional(),
124
+ // Server-side state. All connection info lives here - the spec block
125
+ // only carries the desired configuration, not the runtime hostnames.
126
+ status: z
127
+ .object({
128
+ endpoint_type: z.string().optional(),
129
+ // Resolved hostnames; `hosts.host` is the writable primary.
130
+ hosts: z
131
+ .object({
132
+ host: z.string().optional(),
133
+ read_only_host: z.string().optional(),
134
+ })
135
+ .optional(),
136
+ // Compute state: `INITIALIZING`, `STARTING`, `READY`, `IDLE`, ...
137
+ current_state: z.string().optional(),
138
+ })
139
+ .optional(),
140
+ });
141
+ type Endpoint = z.infer<typeof endpointSchema>;
142
+
143
+ const branchSchema = z.object({
144
+ // Full resource path: `projects/{p}/branches/{b}`.
145
+ name: z.string().optional(),
146
+ status: z
147
+ .object({
148
+ // True for the project's default branch (e.g. `production`).
149
+ default: z.boolean().optional(),
150
+ current_state: z.string().optional(),
151
+ })
152
+ .optional(),
153
+ });
154
+ type Branch = z.infer<typeof branchSchema>;
155
+
156
+ const databaseSchema = z.object({
157
+ // Full resource path: `projects/{p}/branches/{b}/databases/{d}`.
158
+ name: z.string().optional(),
159
+ status: z
160
+ .object({
161
+ // Actual Postgres database name (used as `PGDATABASE`). May differ
162
+ // from the resource id - e.g. resource `databricks-postgres`
163
+ // surfaces as Postgres database `databricks_postgres`.
164
+ postgres_database: z.string().optional(),
165
+ })
166
+ .optional(),
167
+ });
168
+ type Database = z.infer<typeof databaseSchema>;
169
+
87
170
  /**
88
171
  * Lakebase REST list responses follow the Google AIP convention:
89
172
  * `{ <plural-resource>: T[], next_page_token?: string }`. We only read
90
173
  * the first page; for auto-config's "pick something sensible" semantics the
91
174
  * cap is fine.
92
175
  */
93
- interface ListResponse {
94
- next_page_token?: string;
95
- projects?: Project[];
96
- branches?: Branch[];
97
- endpoints?: Endpoint[];
98
- databases?: Database[];
99
- }
176
+ const listResponseSchema = z.object({
177
+ next_page_token: z.string().optional(),
178
+ projects: z.array(projectSchema).optional(),
179
+ branches: z.array(branchSchema).optional(),
180
+ endpoints: z.array(endpointSchema).optional(),
181
+ databases: z.array(databaseSchema).optional(),
182
+ });
100
183
 
101
- interface Project {
102
- /** Full resource path: `projects/{p}`. */
103
- name?: string;
104
- }
184
+ /**
185
+ * Long-running operation envelope returned by mutating REST calls.
186
+ * `done: true` means terminal; check `error` before reading `response`.
187
+ */
188
+ const operationSchema = z.object({
189
+ name: z.string().optional(),
190
+ done: z.boolean().optional(),
191
+ error: z.unknown().optional(),
192
+ response: z.unknown().optional(),
193
+ });
194
+ type Operation = z.infer<typeof operationSchema>;
105
195
 
106
- interface Endpoint {
107
- /** Full resource path: `projects/{p}/branches/{b}/endpoints/{e}`. */
108
- name?: string;
109
- uid?: string;
110
- /**
111
- * Server-side state. All connection info lives here - the spec block
112
- * only carries the desired configuration, not the runtime hostnames.
113
- */
114
- status?: {
115
- endpoint_type?: "ENDPOINT_TYPE_READ_WRITE" | "ENDPOINT_TYPE_READ_ONLY";
116
- /** Resolved hostnames; `hosts.host` is the writable primary. */
117
- hosts?: {
118
- host?: string;
119
- read_only_host?: string;
120
- };
121
- /** Compute state: `INITIALIZING`, `STARTING`, `READY`, `IDLE`, ... */
122
- current_state?: string;
123
- };
196
+ /** `PGPORT` must land inside the TCP port range. */
197
+ const portSchema = z.coerce.number().int().min(1).max(MAX_TCP_PORT);
198
+
199
+ const sslModeSchema = z.enum(SSL_MODES);
200
+
201
+ /**
202
+ * Validate a `PGPORT`-shaped value. Returns `undefined` when unset, and throws
203
+ * a {@link ValidationError} naming `PGPORT` for anything that is not a TCP
204
+ * port, so a typo cannot reach the connection record as `NaN`.
205
+ */
206
+ export function parsePort(value: string | number | undefined): number | undefined {
207
+ if (value === undefined || value === "") return undefined;
208
+ const parsed = portSchema.safeParse(value);
209
+ if (!parsed.success) {
210
+ throw ValidationError.invalidValue("PGPORT", value, `a TCP port between 1 and ${MAX_TCP_PORT}`);
211
+ }
212
+ return parsed.data;
124
213
  }
125
214
 
126
- interface Branch {
127
- /** Full resource path: `projects/{p}/branches/{b}`. */
128
- name?: string;
129
- status?: {
130
- /** True for the project's default branch (e.g. `production`). */
131
- default?: boolean;
132
- current_state?: string;
133
- };
215
+ /**
216
+ * Validate a `PGSSLMODE`-shaped value. Returns `undefined` when unset, and
217
+ * throws a {@link ValidationError} naming `PGSSLMODE` for an unsupported mode
218
+ * rather than handing `pg` a value it will reject at connect time.
219
+ */
220
+ export function parseSslMode(value: string | undefined): SslMode | undefined {
221
+ if (value === undefined || value === "") return undefined;
222
+ const parsed = sslModeSchema.safeParse(value.trim().toLowerCase());
223
+ if (!parsed.success) {
224
+ throw ValidationError.invalidValue("PGSSLMODE", value, SSL_MODES.join(", "));
225
+ }
226
+ return parsed.data;
134
227
  }
135
228
 
136
- interface Database {
137
- /** Full resource path: `projects/{p}/branches/{b}/databases/{d}`. */
138
- name?: string;
139
- status?: {
140
- /**
141
- * Actual Postgres database name (used as `PGDATABASE`). May differ
142
- * from the resource id - e.g. resource `databricks-postgres`
143
- * surfaces as Postgres database `databricks_postgres`.
144
- */
145
- postgres_database?: string;
146
- };
229
+ /**
230
+ * Delay before the next poll attempt: exponential backoff capped at
231
+ * {@link POLL_MAX_DELAY_MS}, plus jitter so several apps booting against the
232
+ * same workspace do not retry in lockstep.
233
+ */
234
+ export function nextPollDelay(attempt: number, baseMs: number): number {
235
+ const backoff = Math.min(baseMs * POLL_BACKOFF_FACTOR ** attempt, POLL_MAX_DELAY_MS);
236
+ const jitter = backoff * POLL_JITTER_RATIO * (Math.random() * 2 - 1);
237
+ return Math.max(0, Math.round(backoff + jitter));
147
238
  }
148
239
 
149
240
  /**
150
- * Long-running operation envelope returned by mutating REST calls.
151
- * `done: true` means terminal; check `error` before reading `response`.
241
+ * Wait out {@link nextPollDelay} before the next attempt. Rejects with the
242
+ * signal's reason when the caller cancels mid-wait, so a poll loop unwinds
243
+ * instead of finishing its backoff first.
152
244
  */
153
- interface Operation {
154
- name?: string;
155
- done?: boolean;
156
- error?: unknown;
157
- response?: unknown;
245
+ export function pollDelay(attempt: number, baseMs: number, signal?: AbortSignal): Promise<void> {
246
+ return async.sleep(nextPollDelay(attempt, baseMs), signal);
158
247
  }
159
248
 
160
249
  /**
@@ -174,7 +263,8 @@ export async function readLakebaseInputs(
174
263
  ): Promise<LakebaseResolverInputs> {
175
264
  const rawAddress = config?.endpoint ?? (await resolveConfigValue("LAKEBASE_ENDPOINT"));
176
265
  const parsed = parseAddress(rawAddress);
177
- const portEnv = await resolveConfigValue("PGPORT");
266
+ const portEnv = parsePort(await resolveConfigValue("PGPORT"));
267
+ const sslModeEnv = parseSslMode(await resolveConfigValue("PGSSLMODE"));
178
268
  return {
179
269
  project: config?.project ?? parsed.project,
180
270
  branch: config?.branch ?? parsed.branch,
@@ -184,11 +274,8 @@ export async function readLakebaseInputs(
184
274
  endpoint: parsed.endpoint,
185
275
  database: config?.database ?? (await resolveConfigValue("PGDATABASE")) ?? parsed.database,
186
276
  host: config?.host ?? (await resolveConfigValue("PGHOST")) ?? parsed.host,
187
- port: config?.port ?? (portEnv ? Number.parseInt(portEnv, 10) : undefined) ?? parsed.port,
188
- sslMode:
189
- config?.sslMode ??
190
- ((await resolveConfigValue("PGSSLMODE")) as SslMode | undefined) ??
191
- parsed.sslMode,
277
+ port: config?.port ?? portEnv ?? parsed.port,
278
+ sslMode: config?.sslMode ?? sslModeEnv ?? parsed.sslMode,
192
279
  autoCreate: config?.autoCreate,
193
280
  };
194
281
  }
@@ -199,9 +286,21 @@ export async function readLakebaseInputs(
199
286
  * Returns immediately without network traffic when env already supplies
200
287
  * `endpoint`, `host`, and `database`. Otherwise issues REST calls and
201
288
  * may auto-create a project (see module docstring).
289
+ *
290
+ * `signal` cancels every REST call and inter-poll sleep.
291
+ *
292
+ * @example
293
+ * import { lakebaseResolver } from "@dbx-tools/appkit";
294
+ *
295
+ * const resolved = await lakebaseResolver.resolveLakebaseConnection(
296
+ * { autoCreate: false },
297
+ * AbortSignal.timeout(60_000),
298
+ * );
299
+ * lakebaseResolver.applyLakebaseToEnv(resolved);
202
300
  */
203
301
  export async function resolveLakebaseConnection(
204
302
  config?: LakebaseResolverInputs,
303
+ signal?: AbortSignal,
205
304
  ): Promise<LakebaseConnection> {
206
305
  const inputs = await readLakebaseInputs(config);
207
306
  let { project, branch, endpoint, database, host } = inputs;
@@ -234,7 +333,7 @@ export async function resolveLakebaseConnection(
234
333
  // Host known but no resource path: scan the workspace to find which
235
334
  // endpoint owns this host so we can populate LAKEBASE_ENDPOINT.
236
335
  if (!project && host) {
237
- const found = await findEndpointByHost(ws, host);
336
+ const found = await findEndpointByHost(ws, host, signal);
238
337
  if (found) {
239
338
  project = found.project;
240
339
  branch = found.branch;
@@ -244,15 +343,15 @@ export async function resolveLakebaseConnection(
244
343
 
245
344
  // No project anywhere in config/env/address: list, pick, or create.
246
345
  if (!project) {
247
- project = await pickOrCreateProject(ws, config?.autoCreate, logger);
346
+ project = await pickOrCreateProject(ws, config?.autoCreate, signal);
248
347
  }
249
348
 
250
349
  if (!branch) {
251
- branch = await pickBranch(ws, project, logger);
350
+ branch = await pickBranch(ws, project, signal);
252
351
  }
253
352
 
254
353
  if (!endpoint) {
255
- const ep = await pickEndpoint(ws, project, branch, logger);
354
+ const ep = await pickEndpoint(ws, project, branch, signal);
256
355
  endpoint = ep.name;
257
356
  host ??= ep.host;
258
357
  }
@@ -265,7 +364,7 @@ export async function resolveLakebaseConnection(
265
364
  parsedEndpoint.project,
266
365
  parsedEndpoint.branch,
267
366
  parsedEndpoint.endpointId,
268
- logger,
367
+ signal,
269
368
  );
270
369
  host = ep.status?.hosts?.host;
271
370
  logger.debug("autopg: resolved host from endpoint", { host });
@@ -273,7 +372,7 @@ export async function resolveLakebaseConnection(
273
372
  }
274
373
 
275
374
  if (!database) {
276
- database = await pickDatabase(ws, project, branch, logger);
375
+ database = await pickDatabase(ws, project, branch, signal);
277
376
  }
278
377
 
279
378
  return { project, branch, endpoint, database, host, port, sslMode };
@@ -295,45 +394,95 @@ export function applyLakebaseToEnv(resolved: LakebaseConnection): void {
295
394
 
296
395
  type WorkspaceClient = ReturnType<typeof getWorkspaceClient>;
297
396
 
298
- /** GET helper that always parses JSON and forwards through `apiClient`. */
299
- async function getJson<T>(ws: WorkspaceClient, path: string): Promise<T> {
300
- const res = await ws.apiClient.request({
397
+ /** The SDK `Context` accepted by the workspace client's own copy of the SDK. */
398
+ type ApiRequestContext = NonNullable<Parameters<WorkspaceClient["apiClient"]["request"]>[1]>;
399
+
400
+ /**
401
+ * Adapt an {@link AbortSignal} into the api client's cancellation context.
402
+ * AppKit resolves a different copy of `@databricks/sdk-experimental` than this
403
+ * package does, and `Context` carries a private field, so the two declarations
404
+ * are nominally incompatible even though the api client only reads
405
+ * `cancellationToken` and `logger` off the object.
406
+ */
407
+ function requestContext(signal: AbortSignal | undefined): ApiRequestContext | undefined {
408
+ return signal ? (toContext(signal) as unknown as ApiRequestContext) : undefined;
409
+ }
410
+
411
+ /**
412
+ * Validate a Lakebase REST body. The full payload is logged rather than thrown
413
+ * so a client never receives upstream detail.
414
+ */
415
+ function parseResponse<T>(schema: z.ZodType<T>, path: string, body: unknown): T {
416
+ const parsed = schema.safeParse(body);
417
+ if (parsed.success) return parsed.data;
418
+ logger.error("autopg: unexpected Lakebase API response", {
301
419
  path,
302
- method: "GET",
303
- headers: new Headers({ Accept: "application/json" }),
304
- raw: false,
420
+ issues: parsed.error.issues,
305
421
  });
306
- return res as T;
422
+ throw new ExecutionError("Lakebase API returned an unexpected response", { context: { path } });
307
423
  }
308
424
 
309
- /** POST helper for create / mutate calls; returns the parsed JSON body. */
425
+ /** GET helper that validates the JSON body and forwards cancellation. */
426
+ async function getJson<T>(
427
+ ws: WorkspaceClient,
428
+ path: string,
429
+ schema: z.ZodType<T>,
430
+ signal?: AbortSignal,
431
+ ): Promise<T> {
432
+ const res = await ws.apiClient.request(
433
+ {
434
+ path,
435
+ method: "GET",
436
+ headers: new Headers({ Accept: "application/json" }),
437
+ raw: false,
438
+ },
439
+ requestContext(signal),
440
+ );
441
+ return parseResponse(schema, path, res);
442
+ }
443
+
444
+ /** POST helper for create / mutate calls; validates the JSON body. */
310
445
  async function postJson<T>(
311
446
  ws: WorkspaceClient,
312
447
  path: string,
448
+ schema: z.ZodType<T>,
313
449
  body: unknown,
314
450
  query?: Record<string, string>,
451
+ signal?: AbortSignal,
315
452
  ): Promise<T> {
316
- const res = await ws.apiClient.request({
317
- path,
318
- method: "POST",
319
- query,
320
- headers: new Headers({
321
- Accept: "application/json",
322
- "Content-Type": "application/json",
323
- }),
324
- raw: false,
325
- payload: body,
326
- });
327
- return res as T;
453
+ const res = await ws.apiClient.request(
454
+ {
455
+ path,
456
+ method: "POST",
457
+ query,
458
+ headers: new Headers({
459
+ Accept: "application/json",
460
+ "Content-Type": "application/json",
461
+ }),
462
+ raw: false,
463
+ payload: body,
464
+ },
465
+ requestContext(signal),
466
+ );
467
+ return parseResponse(schema, path, res);
328
468
  }
329
469
 
330
- async function listProjects(ws: WorkspaceClient): Promise<Project[]> {
331
- const res = await getJson<ListResponse>(ws, `${API_BASE}/projects`);
470
+ async function listProjects(ws: WorkspaceClient, signal?: AbortSignal): Promise<Project[]> {
471
+ const res = await getJson(ws, `${API_BASE}/projects`, listResponseSchema, signal);
332
472
  return res.projects ?? [];
333
473
  }
334
474
 
335
- async function listBranches(ws: WorkspaceClient, project: string): Promise<Branch[]> {
336
- const res = await getJson<ListResponse>(ws, `${API_BASE}/projects/${project}/branches`);
475
+ async function listBranches(
476
+ ws: WorkspaceClient,
477
+ project: string,
478
+ signal?: AbortSignal,
479
+ ): Promise<Branch[]> {
480
+ const res = await getJson(
481
+ ws,
482
+ `${API_BASE}/projects/${project}/branches`,
483
+ listResponseSchema,
484
+ signal,
485
+ );
337
486
  return res.branches ?? [];
338
487
  }
339
488
 
@@ -341,10 +490,13 @@ async function listEndpoints(
341
490
  ws: WorkspaceClient,
342
491
  project: string,
343
492
  branch: string,
493
+ signal?: AbortSignal,
344
494
  ): Promise<Endpoint[]> {
345
- const res = await getJson<ListResponse>(
495
+ const res = await getJson(
346
496
  ws,
347
497
  `${API_BASE}/projects/${project}/branches/${branch}/endpoints`,
498
+ listResponseSchema,
499
+ signal,
348
500
  );
349
501
  return res.endpoints ?? [];
350
502
  }
@@ -353,10 +505,13 @@ async function listDatabases(
353
505
  ws: WorkspaceClient,
354
506
  project: string,
355
507
  branch: string,
508
+ signal?: AbortSignal,
356
509
  ): Promise<Database[]> {
357
- const res = await getJson<ListResponse>(
510
+ const res = await getJson(
358
511
  ws,
359
512
  `${API_BASE}/projects/${project}/branches/${branch}/databases`,
513
+ listResponseSchema,
514
+ signal,
360
515
  );
361
516
  return res.databases ?? [];
362
517
  }
@@ -366,10 +521,13 @@ async function getEndpoint(
366
521
  project: string,
367
522
  branch: string,
368
523
  endpointId: string,
524
+ signal?: AbortSignal,
369
525
  ): Promise<Endpoint> {
370
- return getJson<Endpoint>(
526
+ return getJson(
371
527
  ws,
372
528
  `${API_BASE}/projects/${project}/branches/${branch}/endpoints/${endpointId}`,
529
+ endpointSchema,
530
+ signal,
373
531
  );
374
532
  }
375
533
 
@@ -385,16 +543,17 @@ async function getEndpoint(
385
543
  async function findEndpointByHost(
386
544
  ws: WorkspaceClient,
387
545
  host: string,
546
+ signal?: AbortSignal,
388
547
  ): Promise<{ project: string; branch: string; endpoint: string } | null> {
389
- const projects = await listProjects(ws);
548
+ const projects = await listProjects(ws, signal);
390
549
  for (const p of projects) {
391
550
  const projectId = parseResourcePath(p.name).project;
392
551
  if (!projectId) continue;
393
- const branches = await listBranches(ws, projectId);
552
+ const branches = await listBranches(ws, projectId, signal);
394
553
  for (const b of branches) {
395
554
  const branchId = parseResourcePath(b.name).branch;
396
555
  if (!branchId) continue;
397
- const endpoints = await listEndpoints(ws, projectId, branchId);
556
+ const endpoints = await listEndpoints(ws, projectId, branchId, signal);
398
557
  const match = endpoints.find((e) => e.status?.hosts?.host === host);
399
558
  if (match?.name) {
400
559
  logger.debug("autopg: matched endpoint by host", {
@@ -427,9 +586,9 @@ async function findEndpointByHost(
427
586
  async function pickOrCreateProject(
428
587
  ws: WorkspaceClient,
429
588
  autoCreate: string | false | undefined,
430
- logger: log.Logger,
589
+ signal?: AbortSignal,
431
590
  ): Promise<string> {
432
- const projects = await listProjects(ws);
591
+ const projects = await listProjects(ws, signal);
433
592
  if (projects.length === 1) {
434
593
  const id = parseResourcePath(projects[0]!.name).project;
435
594
  if (id) {
@@ -439,19 +598,21 @@ async function pickOrCreateProject(
439
598
  }
440
599
  if (projects.length === 0) {
441
600
  if (autoCreate === false) {
442
- throw new Error(
443
- "autopg: no Lakebase projects found and `autoCreate: false`; create a project or set config.project / LAKEBASE_ENDPOINT",
601
+ throw ConfigurationError.resourceNotFound(
602
+ "Lakebase project",
603
+ "autoCreate is false; create a project or set config.project / LAKEBASE_ENDPOINT.",
444
604
  );
445
605
  }
446
606
  const id = autoCreate ?? (await defaultProjectId());
447
- return ensureProject(ws, id, logger);
607
+ return ensureProject(ws, id, signal);
448
608
  }
449
609
  const candidates = projects
450
610
  .map((p) => parseResourcePath(p.name).project)
451
611
  .filter((id): id is string => Boolean(id))
452
612
  .join(", ");
453
- throw new Error(
454
- `autopg: multiple projects found; set config.project or pin a project id in LAKEBASE_ENDPOINT. Candidates: ${candidates}`,
613
+ throw ConfigurationError.invalidConnection(
614
+ "Lakebase",
615
+ `Multiple projects found; set config.project or pin a project id in LAKEBASE_ENDPOINT. Candidates: ${candidates}`,
455
616
  );
456
617
  }
457
618
 
@@ -468,8 +629,10 @@ async function defaultProjectId(): Promise<string> {
468
629
  const name = project.name();
469
630
  const slug = string.toSlugWithOptions({ maxLength: PROJECT_ID_MAX_LEN }, name);
470
631
  if (!slug || !/^[a-z]/.test(slug)) {
471
- throw new Error(
472
- `autopg: could not derive a Lakebase project id from project name '${name}'; pass autoCreate explicitly`,
632
+ logger.warn("autopg: project name does not slugify to a Lakebase project id", { name });
633
+ throw ConfigurationError.invalidConnection(
634
+ "Lakebase",
635
+ "Could not derive a project id from the package name; pass autoCreate explicitly.",
473
636
  );
474
637
  }
475
638
  return slug;
@@ -488,17 +651,19 @@ async function defaultProjectId(): Promise<string> {
488
651
  async function ensureProject(
489
652
  ws: WorkspaceClient,
490
653
  projectId: string,
491
- logger: log.Logger,
654
+ signal?: AbortSignal,
492
655
  ): Promise<string> {
493
656
  logger.warn("autopg: no projects found; creating", { project: projectId });
494
657
  try {
495
- const op = await postJson<Operation>(
658
+ const op = await postJson(
496
659
  ws,
497
660
  `${API_BASE}/projects`,
661
+ operationSchema,
498
662
  { spec: { pg_version: DEFAULT_PG_VERSION } },
499
663
  { project_id: projectId },
664
+ signal,
500
665
  );
501
- await waitForOperation(ws, op, logger);
666
+ await waitForOperation(ws, op, signal);
502
667
  logger.info("autopg: created project", { project: projectId });
503
668
  } catch (err) {
504
669
  if (!isAlreadyExistsError(err)) throw err;
@@ -532,6 +697,22 @@ function isAlreadyExistsError(error: unknown): boolean {
532
697
  return false;
533
698
  }
534
699
 
700
+ /**
701
+ * Reduce a Lakebase operation failure to an error safe to propagate: the
702
+ * operation name plus whatever code the payload carries. The payload itself can
703
+ * echo request context, so it goes to the log instead.
704
+ */
705
+ function operationFailed(opName: string, opError: unknown): ExecutionError {
706
+ logger.error("autopg: operation failed", { operation: opName, error: opError });
707
+ const code = object.isRecord(opError) ? (opError.code ?? opError.reason) : undefined;
708
+ return new ExecutionError(`Lakebase operation failed: ${opName}`, {
709
+ context: {
710
+ operation: opName,
711
+ errorCode: typeof code === "string" || typeof code === "number" ? String(code) : "unknown",
712
+ },
713
+ });
714
+ }
715
+
535
716
  /**
536
717
  * Poll a Lakebase long-running operation until `done: true`. Returns
537
718
  * the final operation envelope (which may carry `response` or `error`).
@@ -539,38 +720,39 @@ function isAlreadyExistsError(error: unknown): boolean {
539
720
  * Throws when:
540
721
  * - the response carries an `error` field;
541
722
  * - `op.name` is missing (nothing to poll);
542
- * - the timeout elapses before `done: true`.
723
+ * - the timeout elapses before `done: true`;
724
+ * - `signal` aborts mid-wait.
543
725
  */
544
726
  async function waitForOperation(
545
727
  ws: WorkspaceClient,
546
728
  op: Operation,
547
- logger: log.Logger,
729
+ signal?: AbortSignal,
548
730
  ): Promise<Operation> {
549
731
  if (op.done) {
550
732
  if (op.error) {
551
- throw new Error(`autopg: operation failed: ${JSON.stringify(op.error)}`);
733
+ throw operationFailed(op.name ?? "unknown", op.error);
552
734
  }
553
735
  return op;
554
736
  }
555
737
  const opName = op.name;
556
738
  if (!opName) {
557
- throw new Error("autopg: operation response has no name to poll");
739
+ throw ExecutionError.missingData("operation name");
558
740
  }
559
741
  const start = Date.now();
560
- while (Date.now() - start < OPERATION_TIMEOUT_MS) {
561
- await sleep(OPERATION_POLL_MS);
562
- const current = await getJson<Operation>(ws, `${API_BASE}/${opName}`);
742
+ for (let attempt = 0; Date.now() - start < OPERATION_TIMEOUT_MS; attempt++) {
743
+ await pollDelay(attempt, OPERATION_POLL_MS, signal);
744
+ const current = await getJson(ws, `${API_BASE}/${opName}`, operationSchema, signal);
563
745
  logger.debug("autopg: operation status", { op: opName, done: current.done });
564
746
  if (current.done) {
565
747
  if (current.error) {
566
- throw new Error(`autopg: operation '${opName}' failed: ${JSON.stringify(current.error)}`);
748
+ throw operationFailed(opName, current.error);
567
749
  }
568
750
  return current;
569
751
  }
570
752
  }
571
- throw new Error(
572
- `autopg: operation '${opName}' did not complete within ${OPERATION_TIMEOUT_MS}ms`,
573
- );
753
+ throw new ExecutionError(`Lakebase operation did not complete: ${opName}`, {
754
+ context: { operation: opName, timeoutMs: OPERATION_TIMEOUT_MS },
755
+ });
574
756
  }
575
757
 
576
758
  /**
@@ -584,26 +766,32 @@ async function waitEndpointReady(
584
766
  project: string,
585
767
  branch: string,
586
768
  endpointId: string,
587
- logger: log.Logger,
769
+ signal?: AbortSignal,
588
770
  ): Promise<Endpoint> {
589
771
  const start = Date.now();
590
772
  let last: Endpoint | null = null;
591
- while (Date.now() - start < ENDPOINT_READY_TIMEOUT_MS) {
592
- last = await getEndpoint(ws, project, branch, endpointId);
773
+ for (let attempt = 0; Date.now() - start < ENDPOINT_READY_TIMEOUT_MS; attempt++) {
774
+ last = await getEndpoint(ws, project, branch, endpointId, signal);
593
775
  const state = last.status?.current_state;
594
- if (state === "READY" || state === "IDLE") return last;
595
- if (last.status?.hosts?.host && state !== "INITIALIZING") {
776
+ if (state && CONNECTABLE_ENDPOINT_STATES.has(state)) return last;
777
+ if (last.status?.hosts?.host && state !== INITIALIZING_ENDPOINT_STATE) {
596
778
  // Compute is in some other state (STARTING, etc.) but hostname is
597
779
  // already published - good enough to connect; lakebase's OAuth
598
780
  // token request will wake it.
599
781
  return last;
600
782
  }
601
783
  logger.debug("autopg: waiting for endpoint", { endpointId, state });
602
- await sleep(ENDPOINT_READY_POLL_MS);
784
+ await pollDelay(attempt, ENDPOINT_READY_POLL_MS, signal);
603
785
  }
604
- throw new Error(
605
- `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"})`,
606
- );
786
+ throw new ExecutionError(`Lakebase endpoint did not become ready: ${endpointId}`, {
787
+ context: {
788
+ project,
789
+ branch,
790
+ endpointId,
791
+ timeoutMs: ENDPOINT_READY_TIMEOUT_MS,
792
+ lastState: last?.status?.current_state ?? "unknown",
793
+ },
794
+ });
607
795
  }
608
796
 
609
797
  /**
@@ -615,11 +803,14 @@ async function waitEndpointReady(
615
803
  async function pickBranch(
616
804
  ws: WorkspaceClient,
617
805
  project: string,
618
- logger: log.Logger,
806
+ signal?: AbortSignal,
619
807
  ): Promise<string> {
620
- const branches = await listBranches(ws, project);
808
+ const branches = await listBranches(ws, project, signal);
621
809
  if (branches.length === 0) {
622
- throw new Error(`autopg: project '${project}' has no branches; cannot resolve a default`);
810
+ throw ConfigurationError.resourceNotFound(
811
+ `Lakebase branch in project '${project}'`,
812
+ "The project has no branches; create one or set config.branch.",
813
+ );
623
814
  }
624
815
  const flagged = branches.find((b) => b.status?.default === true);
625
816
  const choice =
@@ -630,8 +821,9 @@ async function pickBranch(
630
821
  .map((b) => parseResourcePath(b.name).branch)
631
822
  .filter((id): id is string => Boolean(id))
632
823
  .join(", ");
633
- throw new Error(
634
- `autopg: project '${project}' has multiple branches and none marked default; set config.branch or include the branch in LAKEBASE_ENDPOINT. Candidates: ${candidates}`,
824
+ throw ConfigurationError.invalidConnection(
825
+ "Lakebase",
826
+ `Project '${project}' has multiple branches and none marked default; set config.branch or include the branch in LAKEBASE_ENDPOINT. Candidates: ${candidates}`,
635
827
  );
636
828
  }
637
829
  logger.debug("autopg: resolved branch", { project, branch: choice });
@@ -649,21 +841,23 @@ async function pickEndpoint(
649
841
  ws: WorkspaceClient,
650
842
  project: string,
651
843
  branch: string,
652
- logger: log.Logger,
844
+ signal?: AbortSignal,
653
845
  ): Promise<{ name: string; host?: string }> {
654
- const endpoints = await listEndpoints(ws, project, branch);
846
+ const endpoints = await listEndpoints(ws, project, branch, signal);
655
847
  if (endpoints.length === 0) {
656
- throw new Error(
657
- `autopg: branch 'projects/${project}/branches/${branch}' has no endpoints; cannot resolve LAKEBASE_ENDPOINT`,
848
+ throw ConfigurationError.resourceNotFound(
849
+ `Lakebase endpoint in 'projects/${project}/branches/${branch}'`,
850
+ "The branch has no endpoints; create one or set LAKEBASE_ENDPOINT.",
658
851
  );
659
852
  }
660
853
  const primary =
661
- endpoints.find((e) => e.status?.endpoint_type === "ENDPOINT_TYPE_READ_WRITE") ??
854
+ endpoints.find((e) => e.status?.endpoint_type === READ_WRITE_ENDPOINT_TYPE) ??
662
855
  (endpoints.length === 1 ? endpoints[0] : undefined);
663
856
  if (!primary?.name) {
664
857
  const names = endpoints.map((e) => e.name).filter(Boolean);
665
- throw new Error(
666
- `autopg: branch has no primary READ_WRITE endpoint; set LAKEBASE_ENDPOINT or config.endpoint. Candidates: ${names.join(", ")}`,
858
+ throw ConfigurationError.invalidConnection(
859
+ "Lakebase",
860
+ `Branch has no primary read-write endpoint; set LAKEBASE_ENDPOINT or config.endpoint. Candidates: ${names.join(", ")}`,
667
861
  );
668
862
  }
669
863
  const host = primary.status?.hosts?.host;
@@ -684,22 +878,24 @@ async function pickDatabase(
684
878
  ws: WorkspaceClient,
685
879
  project: string,
686
880
  branch: string,
687
- logger: log.Logger,
881
+ signal?: AbortSignal,
688
882
  ): Promise<string> {
689
- const databases = await listDatabases(ws, project, branch);
883
+ const databases = await listDatabases(ws, project, branch, signal);
690
884
  if (databases.length === 0) {
691
- throw new Error(
692
- `autopg: branch 'projects/${project}/branches/${branch}' has no databases; cannot resolve PGDATABASE`,
885
+ throw ConfigurationError.resourceNotFound(
886
+ `Lakebase database in 'projects/${project}/branches/${branch}'`,
887
+ "The branch has no databases; create one or set PGDATABASE.",
693
888
  );
694
889
  }
695
890
  const names = databases
696
891
  .map((d) => d.status?.postgres_database)
697
892
  .filter((n): n is string => Boolean(n));
698
893
  const choice =
699
- names.find((n) => n === "databricks_postgres") ?? (names.length === 1 ? names[0] : undefined);
894
+ names.find((n) => n === DEFAULT_DATABASE) ?? (names.length === 1 ? names[0] : undefined);
700
895
  if (!choice) {
701
- throw new Error(
702
- `autopg: multiple databases and no 'databricks_postgres'; set PGDATABASE or config.database. Candidates: ${names.join(", ")}`,
896
+ throw ConfigurationError.invalidConnection(
897
+ "Lakebase",
898
+ `Multiple databases and no '${DEFAULT_DATABASE}'; set PGDATABASE or config.database. Candidates: ${names.join(", ")}`,
703
899
  );
704
900
  }
705
901
  logger.debug("autopg: resolved database", { database: choice });