@dbx-tools/appkit 0.3.29 → 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,14 +35,22 @@
35
35
  * @module
36
36
  */
37
37
 
38
- import { getWorkspaceClient } from "@databricks/appkit";
38
+ import {
39
+ ConfigurationError,
40
+ ExecutionError,
41
+ getWorkspaceClient,
42
+ ValidationError,
43
+ } from "@databricks/appkit";
39
44
  import { project } from "@dbx-tools/core";
40
- import { async, log, string } from "@dbx-tools/shared-core";
45
+ import { async, log, object, string } from "@dbx-tools/shared-core";
46
+ import { z } from "zod";
41
47
  import { resolveConfigValue } from "./config";
42
48
 
49
+ import { MAX_TCP_PORT, toContext } from "./databricks";
43
50
  import {
44
51
  parseAddress,
45
52
  parseResourcePath,
53
+ SSL_MODES,
46
54
  type LakebaseConnectionInputs,
47
55
  type SslMode,
48
56
  } from "./pgaddress";
@@ -58,6 +66,19 @@ const OPERATION_TIMEOUT_MS = 5 * 60_000;
58
66
  const OPERATION_POLL_MS = 2_000;
59
67
  const ENDPOINT_READY_TIMEOUT_MS = 5 * 60_000;
60
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";
61
82
 
62
83
  /**
63
84
  * User-supplied Lakebase inputs (config or env) before any API resolution.
@@ -83,77 +104,146 @@ export interface LakebaseConnection extends LakebaseConnectionInputs {
83
104
  sslMode: SslMode;
84
105
  }
85
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
+
86
170
  /**
87
171
  * Lakebase REST list responses follow the Google AIP convention:
88
172
  * `{ <plural-resource>: T[], next_page_token?: string }`. We only read
89
173
  * the first page; for auto-config's "pick something sensible" semantics the
90
174
  * cap is fine.
91
175
  */
92
- interface ListResponse {
93
- next_page_token?: string;
94
- projects?: Project[];
95
- branches?: Branch[];
96
- endpoints?: Endpoint[];
97
- databases?: Database[];
98
- }
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
+ });
99
183
 
100
- interface Project {
101
- /** Full resource path: `projects/{p}`. */
102
- name?: string;
103
- }
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>;
104
195
 
105
- interface Endpoint {
106
- /** Full resource path: `projects/{p}/branches/{b}/endpoints/{e}`. */
107
- name?: string;
108
- uid?: string;
109
- /**
110
- * Server-side state. All connection info lives here - the spec block
111
- * only carries the desired configuration, not the runtime hostnames.
112
- */
113
- status?: {
114
- endpoint_type?: "ENDPOINT_TYPE_READ_WRITE" | "ENDPOINT_TYPE_READ_ONLY";
115
- /** Resolved hostnames; `hosts.host` is the writable primary. */
116
- hosts?: {
117
- host?: string;
118
- read_only_host?: string;
119
- };
120
- /** Compute state: `INITIALIZING`, `STARTING`, `READY`, `IDLE`, ... */
121
- current_state?: string;
122
- };
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;
123
213
  }
124
214
 
125
- interface Branch {
126
- /** Full resource path: `projects/{p}/branches/{b}`. */
127
- name?: string;
128
- status?: {
129
- /** True for the project's default branch (e.g. `production`). */
130
- default?: boolean;
131
- current_state?: string;
132
- };
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;
133
227
  }
134
228
 
135
- interface Database {
136
- /** Full resource path: `projects/{p}/branches/{b}/databases/{d}`. */
137
- name?: string;
138
- status?: {
139
- /**
140
- * Actual Postgres database name (used as `PGDATABASE`). May differ
141
- * from the resource id - e.g. resource `databricks-postgres`
142
- * surfaces as Postgres database `databricks_postgres`.
143
- */
144
- postgres_database?: string;
145
- };
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));
146
238
  }
147
239
 
148
240
  /**
149
- * Long-running operation envelope returned by mutating REST calls.
150
- * `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.
151
244
  */
152
- interface Operation {
153
- name?: string;
154
- done?: boolean;
155
- error?: unknown;
156
- response?: unknown;
245
+ export function pollDelay(attempt: number, baseMs: number, signal?: AbortSignal): Promise<void> {
246
+ return async.sleep(nextPollDelay(attempt, baseMs), signal);
157
247
  }
158
248
 
159
249
  /**
@@ -173,7 +263,8 @@ export async function readLakebaseInputs(
173
263
  ): Promise<LakebaseResolverInputs> {
174
264
  const rawAddress = config?.endpoint ?? (await resolveConfigValue("LAKEBASE_ENDPOINT"));
175
265
  const parsed = parseAddress(rawAddress);
176
- const portEnv = await resolveConfigValue("PGPORT");
266
+ const portEnv = parsePort(await resolveConfigValue("PGPORT"));
267
+ const sslModeEnv = parseSslMode(await resolveConfigValue("PGSSLMODE"));
177
268
  return {
178
269
  project: config?.project ?? parsed.project,
179
270
  branch: config?.branch ?? parsed.branch,
@@ -183,11 +274,8 @@ export async function readLakebaseInputs(
183
274
  endpoint: parsed.endpoint,
184
275
  database: config?.database ?? (await resolveConfigValue("PGDATABASE")) ?? parsed.database,
185
276
  host: config?.host ?? (await resolveConfigValue("PGHOST")) ?? parsed.host,
186
- port: config?.port ?? (portEnv ? Number.parseInt(portEnv, 10) : undefined) ?? parsed.port,
187
- sslMode:
188
- config?.sslMode ??
189
- ((await resolveConfigValue("PGSSLMODE")) as SslMode | undefined) ??
190
- parsed.sslMode,
277
+ port: config?.port ?? portEnv ?? parsed.port,
278
+ sslMode: config?.sslMode ?? sslModeEnv ?? parsed.sslMode,
191
279
  autoCreate: config?.autoCreate,
192
280
  };
193
281
  }
@@ -198,9 +286,21 @@ export async function readLakebaseInputs(
198
286
  * Returns immediately without network traffic when env already supplies
199
287
  * `endpoint`, `host`, and `database`. Otherwise issues REST calls and
200
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);
201
300
  */
202
301
  export async function resolveLakebaseConnection(
203
302
  config?: LakebaseResolverInputs,
303
+ signal?: AbortSignal,
204
304
  ): Promise<LakebaseConnection> {
205
305
  const inputs = await readLakebaseInputs(config);
206
306
  let { project, branch, endpoint, database, host } = inputs;
@@ -233,7 +333,7 @@ export async function resolveLakebaseConnection(
233
333
  // Host known but no resource path: scan the workspace to find which
234
334
  // endpoint owns this host so we can populate LAKEBASE_ENDPOINT.
235
335
  if (!project && host) {
236
- const found = await findEndpointByHost(ws, host);
336
+ const found = await findEndpointByHost(ws, host, signal);
237
337
  if (found) {
238
338
  project = found.project;
239
339
  branch = found.branch;
@@ -243,15 +343,15 @@ export async function resolveLakebaseConnection(
243
343
 
244
344
  // No project anywhere in config/env/address: list, pick, or create.
245
345
  if (!project) {
246
- project = await pickOrCreateProject(ws, config?.autoCreate, logger);
346
+ project = await pickOrCreateProject(ws, config?.autoCreate, signal);
247
347
  }
248
348
 
249
349
  if (!branch) {
250
- branch = await pickBranch(ws, project, logger);
350
+ branch = await pickBranch(ws, project, signal);
251
351
  }
252
352
 
253
353
  if (!endpoint) {
254
- const ep = await pickEndpoint(ws, project, branch, logger);
354
+ const ep = await pickEndpoint(ws, project, branch, signal);
255
355
  endpoint = ep.name;
256
356
  host ??= ep.host;
257
357
  }
@@ -264,7 +364,7 @@ export async function resolveLakebaseConnection(
264
364
  parsedEndpoint.project,
265
365
  parsedEndpoint.branch,
266
366
  parsedEndpoint.endpointId,
267
- logger,
367
+ signal,
268
368
  );
269
369
  host = ep.status?.hosts?.host;
270
370
  logger.debug("autopg: resolved host from endpoint", { host });
@@ -272,7 +372,7 @@ export async function resolveLakebaseConnection(
272
372
  }
273
373
 
274
374
  if (!database) {
275
- database = await pickDatabase(ws, project, branch, logger);
375
+ database = await pickDatabase(ws, project, branch, signal);
276
376
  }
277
377
 
278
378
  return { project, branch, endpoint, database, host, port, sslMode };
@@ -294,45 +394,95 @@ export function applyLakebaseToEnv(resolved: LakebaseConnection): void {
294
394
 
295
395
  type WorkspaceClient = ReturnType<typeof getWorkspaceClient>;
296
396
 
297
- /** GET helper that always parses JSON and forwards through `apiClient`. */
298
- async function getJson<T>(ws: WorkspaceClient, path: string): Promise<T> {
299
- 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", {
300
419
  path,
301
- method: "GET",
302
- headers: new Headers({ Accept: "application/json" }),
303
- raw: false,
420
+ issues: parsed.error.issues,
304
421
  });
305
- return res as T;
422
+ throw new ExecutionError("Lakebase API returned an unexpected response", { context: { path } });
306
423
  }
307
424
 
308
- /** 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. */
309
445
  async function postJson<T>(
310
446
  ws: WorkspaceClient,
311
447
  path: string,
448
+ schema: z.ZodType<T>,
312
449
  body: unknown,
313
450
  query?: Record<string, string>,
451
+ signal?: AbortSignal,
314
452
  ): Promise<T> {
315
- const res = await ws.apiClient.request({
316
- path,
317
- method: "POST",
318
- query,
319
- headers: new Headers({
320
- Accept: "application/json",
321
- "Content-Type": "application/json",
322
- }),
323
- raw: false,
324
- payload: body,
325
- });
326
- 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);
327
468
  }
328
469
 
329
- async function listProjects(ws: WorkspaceClient): Promise<Project[]> {
330
- 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);
331
472
  return res.projects ?? [];
332
473
  }
333
474
 
334
- async function listBranches(ws: WorkspaceClient, project: string): Promise<Branch[]> {
335
- 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
+ );
336
486
  return res.branches ?? [];
337
487
  }
338
488
 
@@ -340,10 +490,13 @@ async function listEndpoints(
340
490
  ws: WorkspaceClient,
341
491
  project: string,
342
492
  branch: string,
493
+ signal?: AbortSignal,
343
494
  ): Promise<Endpoint[]> {
344
- const res = await getJson<ListResponse>(
495
+ const res = await getJson(
345
496
  ws,
346
497
  `${API_BASE}/projects/${project}/branches/${branch}/endpoints`,
498
+ listResponseSchema,
499
+ signal,
347
500
  );
348
501
  return res.endpoints ?? [];
349
502
  }
@@ -352,10 +505,13 @@ async function listDatabases(
352
505
  ws: WorkspaceClient,
353
506
  project: string,
354
507
  branch: string,
508
+ signal?: AbortSignal,
355
509
  ): Promise<Database[]> {
356
- const res = await getJson<ListResponse>(
510
+ const res = await getJson(
357
511
  ws,
358
512
  `${API_BASE}/projects/${project}/branches/${branch}/databases`,
513
+ listResponseSchema,
514
+ signal,
359
515
  );
360
516
  return res.databases ?? [];
361
517
  }
@@ -365,10 +521,13 @@ async function getEndpoint(
365
521
  project: string,
366
522
  branch: string,
367
523
  endpointId: string,
524
+ signal?: AbortSignal,
368
525
  ): Promise<Endpoint> {
369
- return getJson<Endpoint>(
526
+ return getJson(
370
527
  ws,
371
528
  `${API_BASE}/projects/${project}/branches/${branch}/endpoints/${endpointId}`,
529
+ endpointSchema,
530
+ signal,
372
531
  );
373
532
  }
374
533
 
@@ -384,16 +543,17 @@ async function getEndpoint(
384
543
  async function findEndpointByHost(
385
544
  ws: WorkspaceClient,
386
545
  host: string,
546
+ signal?: AbortSignal,
387
547
  ): Promise<{ project: string; branch: string; endpoint: string } | null> {
388
- const projects = await listProjects(ws);
548
+ const projects = await listProjects(ws, signal);
389
549
  for (const p of projects) {
390
550
  const projectId = parseResourcePath(p.name).project;
391
551
  if (!projectId) continue;
392
- const branches = await listBranches(ws, projectId);
552
+ const branches = await listBranches(ws, projectId, signal);
393
553
  for (const b of branches) {
394
554
  const branchId = parseResourcePath(b.name).branch;
395
555
  if (!branchId) continue;
396
- const endpoints = await listEndpoints(ws, projectId, branchId);
556
+ const endpoints = await listEndpoints(ws, projectId, branchId, signal);
397
557
  const match = endpoints.find((e) => e.status?.hosts?.host === host);
398
558
  if (match?.name) {
399
559
  logger.debug("autopg: matched endpoint by host", {
@@ -426,9 +586,9 @@ async function findEndpointByHost(
426
586
  async function pickOrCreateProject(
427
587
  ws: WorkspaceClient,
428
588
  autoCreate: string | false | undefined,
429
- logger: log.Logger,
589
+ signal?: AbortSignal,
430
590
  ): Promise<string> {
431
- const projects = await listProjects(ws);
591
+ const projects = await listProjects(ws, signal);
432
592
  if (projects.length === 1) {
433
593
  const id = parseResourcePath(projects[0]!.name).project;
434
594
  if (id) {
@@ -438,19 +598,21 @@ async function pickOrCreateProject(
438
598
  }
439
599
  if (projects.length === 0) {
440
600
  if (autoCreate === false) {
441
- throw new Error(
442
- "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.",
443
604
  );
444
605
  }
445
606
  const id = autoCreate ?? (await defaultProjectId());
446
- return ensureProject(ws, id, logger);
607
+ return ensureProject(ws, id, signal);
447
608
  }
448
609
  const candidates = projects
449
610
  .map((p) => parseResourcePath(p.name).project)
450
611
  .filter((id): id is string => Boolean(id))
451
612
  .join(", ");
452
- throw new Error(
453
- `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}`,
454
616
  );
455
617
  }
456
618
 
@@ -467,8 +629,10 @@ async function defaultProjectId(): Promise<string> {
467
629
  const name = project.name();
468
630
  const slug = string.toSlugWithOptions({ maxLength: PROJECT_ID_MAX_LEN }, name);
469
631
  if (!slug || !/^[a-z]/.test(slug)) {
470
- throw new Error(
471
- `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.",
472
636
  );
473
637
  }
474
638
  return slug;
@@ -487,17 +651,19 @@ async function defaultProjectId(): Promise<string> {
487
651
  async function ensureProject(
488
652
  ws: WorkspaceClient,
489
653
  projectId: string,
490
- logger: log.Logger,
654
+ signal?: AbortSignal,
491
655
  ): Promise<string> {
492
656
  logger.warn("autopg: no projects found; creating", { project: projectId });
493
657
  try {
494
- const op = await postJson<Operation>(
658
+ const op = await postJson(
495
659
  ws,
496
660
  `${API_BASE}/projects`,
661
+ operationSchema,
497
662
  { spec: { pg_version: DEFAULT_PG_VERSION } },
498
663
  { project_id: projectId },
664
+ signal,
499
665
  );
500
- await waitForOperation(ws, op, logger);
666
+ await waitForOperation(ws, op, signal);
501
667
  logger.info("autopg: created project", { project: projectId });
502
668
  } catch (err) {
503
669
  if (!isAlreadyExistsError(err)) throw err;
@@ -531,6 +697,22 @@ function isAlreadyExistsError(error: unknown): boolean {
531
697
  return false;
532
698
  }
533
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
+
534
716
  /**
535
717
  * Poll a Lakebase long-running operation until `done: true`. Returns
536
718
  * the final operation envelope (which may carry `response` or `error`).
@@ -538,38 +720,39 @@ function isAlreadyExistsError(error: unknown): boolean {
538
720
  * Throws when:
539
721
  * - the response carries an `error` field;
540
722
  * - `op.name` is missing (nothing to poll);
541
- * - the timeout elapses before `done: true`.
723
+ * - the timeout elapses before `done: true`;
724
+ * - `signal` aborts mid-wait.
542
725
  */
543
726
  async function waitForOperation(
544
727
  ws: WorkspaceClient,
545
728
  op: Operation,
546
- logger: log.Logger,
729
+ signal?: AbortSignal,
547
730
  ): Promise<Operation> {
548
731
  if (op.done) {
549
732
  if (op.error) {
550
- throw new Error(`autopg: operation failed: ${JSON.stringify(op.error)}`);
733
+ throw operationFailed(op.name ?? "unknown", op.error);
551
734
  }
552
735
  return op;
553
736
  }
554
737
  const opName = op.name;
555
738
  if (!opName) {
556
- throw new Error("autopg: operation response has no name to poll");
739
+ throw ExecutionError.missingData("operation name");
557
740
  }
558
741
  const start = Date.now();
559
- while (Date.now() - start < OPERATION_TIMEOUT_MS) {
560
- await async.sleep(OPERATION_POLL_MS);
561
- 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);
562
745
  logger.debug("autopg: operation status", { op: opName, done: current.done });
563
746
  if (current.done) {
564
747
  if (current.error) {
565
- throw new Error(`autopg: operation '${opName}' failed: ${JSON.stringify(current.error)}`);
748
+ throw operationFailed(opName, current.error);
566
749
  }
567
750
  return current;
568
751
  }
569
752
  }
570
- throw new Error(
571
- `autopg: operation '${opName}' did not complete within ${OPERATION_TIMEOUT_MS}ms`,
572
- );
753
+ throw new ExecutionError(`Lakebase operation did not complete: ${opName}`, {
754
+ context: { operation: opName, timeoutMs: OPERATION_TIMEOUT_MS },
755
+ });
573
756
  }
574
757
 
575
758
  /**
@@ -583,26 +766,32 @@ async function waitEndpointReady(
583
766
  project: string,
584
767
  branch: string,
585
768
  endpointId: string,
586
- logger: log.Logger,
769
+ signal?: AbortSignal,
587
770
  ): Promise<Endpoint> {
588
771
  const start = Date.now();
589
772
  let last: Endpoint | null = null;
590
- while (Date.now() - start < ENDPOINT_READY_TIMEOUT_MS) {
591
- 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);
592
775
  const state = last.status?.current_state;
593
- if (state === "READY" || state === "IDLE") return last;
594
- 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) {
595
778
  // Compute is in some other state (STARTING, etc.) but hostname is
596
779
  // already published - good enough to connect; lakebase's OAuth
597
780
  // token request will wake it.
598
781
  return last;
599
782
  }
600
783
  logger.debug("autopg: waiting for endpoint", { endpointId, state });
601
- await async.sleep(ENDPOINT_READY_POLL_MS);
784
+ await pollDelay(attempt, ENDPOINT_READY_POLL_MS, signal);
602
785
  }
603
- throw new Error(
604
- `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"})`,
605
- );
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
+ });
606
795
  }
607
796
 
608
797
  /**
@@ -614,11 +803,14 @@ async function waitEndpointReady(
614
803
  async function pickBranch(
615
804
  ws: WorkspaceClient,
616
805
  project: string,
617
- logger: log.Logger,
806
+ signal?: AbortSignal,
618
807
  ): Promise<string> {
619
- const branches = await listBranches(ws, project);
808
+ const branches = await listBranches(ws, project, signal);
620
809
  if (branches.length === 0) {
621
- 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
+ );
622
814
  }
623
815
  const flagged = branches.find((b) => b.status?.default === true);
624
816
  const choice =
@@ -629,8 +821,9 @@ async function pickBranch(
629
821
  .map((b) => parseResourcePath(b.name).branch)
630
822
  .filter((id): id is string => Boolean(id))
631
823
  .join(", ");
632
- throw new Error(
633
- `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}`,
634
827
  );
635
828
  }
636
829
  logger.debug("autopg: resolved branch", { project, branch: choice });
@@ -648,21 +841,23 @@ async function pickEndpoint(
648
841
  ws: WorkspaceClient,
649
842
  project: string,
650
843
  branch: string,
651
- logger: log.Logger,
844
+ signal?: AbortSignal,
652
845
  ): Promise<{ name: string; host?: string }> {
653
- const endpoints = await listEndpoints(ws, project, branch);
846
+ const endpoints = await listEndpoints(ws, project, branch, signal);
654
847
  if (endpoints.length === 0) {
655
- throw new Error(
656
- `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.",
657
851
  );
658
852
  }
659
853
  const primary =
660
- endpoints.find((e) => e.status?.endpoint_type === "ENDPOINT_TYPE_READ_WRITE") ??
854
+ endpoints.find((e) => e.status?.endpoint_type === READ_WRITE_ENDPOINT_TYPE) ??
661
855
  (endpoints.length === 1 ? endpoints[0] : undefined);
662
856
  if (!primary?.name) {
663
857
  const names = endpoints.map((e) => e.name).filter(Boolean);
664
- throw new Error(
665
- `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(", ")}`,
666
861
  );
667
862
  }
668
863
  const host = primary.status?.hosts?.host;
@@ -683,22 +878,24 @@ async function pickDatabase(
683
878
  ws: WorkspaceClient,
684
879
  project: string,
685
880
  branch: string,
686
- logger: log.Logger,
881
+ signal?: AbortSignal,
687
882
  ): Promise<string> {
688
- const databases = await listDatabases(ws, project, branch);
883
+ const databases = await listDatabases(ws, project, branch, signal);
689
884
  if (databases.length === 0) {
690
- throw new Error(
691
- `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.",
692
888
  );
693
889
  }
694
890
  const names = databases
695
891
  .map((d) => d.status?.postgres_database)
696
892
  .filter((n): n is string => Boolean(n));
697
893
  const choice =
698
- 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);
699
895
  if (!choice) {
700
- throw new Error(
701
- `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(", ")}`,
702
899
  );
703
900
  }
704
901
  logger.debug("autopg: resolved database", { database: choice });