@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.
- package/README.md +77 -29
- package/index.ts +1 -0
- package/package.json +4 -4
- package/src/appkit.ts +21 -2
- package/src/config.ts +36 -18
- package/src/create-app.ts +104 -26
- package/src/databricks.ts +28 -3
- package/src/lakebase-resolver.ts +347 -151
- package/src/pgaddress.ts +28 -9
- package/src/plugin.ts +17 -2
- package/src/provision.ts +38 -6
- package/test/config.test.ts +63 -0
- package/test/lakebase-resolver.test.ts +82 -0
- package/test/pgaddress.test.ts +76 -0
- package/test/plugin.test.ts +64 -0
- package/test/provision.test.ts +35 -0
package/src/lakebase-resolver.ts
CHANGED
|
@@ -35,15 +35,22 @@
|
|
|
35
35
|
* @module
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
|
-
import {
|
|
39
|
-
|
|
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
|
-
|
|
94
|
-
next_page_token
|
|
95
|
-
projects
|
|
96
|
-
branches
|
|
97
|
-
endpoints
|
|
98
|
-
databases
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
*
|
|
151
|
-
*
|
|
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
|
-
|
|
154
|
-
|
|
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 ??
|
|
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,
|
|
346
|
+
project = await pickOrCreateProject(ws, config?.autoCreate, signal);
|
|
248
347
|
}
|
|
249
348
|
|
|
250
349
|
if (!branch) {
|
|
251
|
-
branch = await pickBranch(ws, project,
|
|
350
|
+
branch = await pickBranch(ws, project, signal);
|
|
252
351
|
}
|
|
253
352
|
|
|
254
353
|
if (!endpoint) {
|
|
255
|
-
const ep = await pickEndpoint(ws, project, branch,
|
|
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
|
-
|
|
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,
|
|
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
|
-
/**
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
303
|
-
headers: new Headers({ Accept: "application/json" }),
|
|
304
|
-
raw: false,
|
|
420
|
+
issues: parsed.error.issues,
|
|
305
421
|
});
|
|
306
|
-
|
|
422
|
+
throw new ExecutionError("Lakebase API returned an unexpected response", { context: { path } });
|
|
307
423
|
}
|
|
308
424
|
|
|
309
|
-
/**
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
|
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(
|
|
336
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
443
|
-
"
|
|
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,
|
|
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
|
|
454
|
-
|
|
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
|
-
|
|
472
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
729
|
+
signal?: AbortSignal,
|
|
548
730
|
): Promise<Operation> {
|
|
549
731
|
if (op.done) {
|
|
550
732
|
if (op.error) {
|
|
551
|
-
throw
|
|
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
|
|
739
|
+
throw ExecutionError.missingData("operation name");
|
|
558
740
|
}
|
|
559
741
|
const start = Date.now();
|
|
560
|
-
|
|
561
|
-
await
|
|
562
|
-
const current = await getJson
|
|
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
|
|
748
|
+
throw operationFailed(opName, current.error);
|
|
567
749
|
}
|
|
568
750
|
return current;
|
|
569
751
|
}
|
|
570
752
|
}
|
|
571
|
-
throw new
|
|
572
|
-
|
|
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
|
-
|
|
769
|
+
signal?: AbortSignal,
|
|
588
770
|
): Promise<Endpoint> {
|
|
589
771
|
const start = Date.now();
|
|
590
772
|
let last: Endpoint | null = null;
|
|
591
|
-
|
|
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
|
|
595
|
-
if (last.status?.hosts?.host && state !==
|
|
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
|
|
784
|
+
await pollDelay(attempt, ENDPOINT_READY_POLL_MS, signal);
|
|
603
785
|
}
|
|
604
|
-
throw new
|
|
605
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
634
|
-
|
|
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
|
-
|
|
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
|
|
657
|
-
`
|
|
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 ===
|
|
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
|
|
666
|
-
|
|
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
|
-
|
|
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
|
|
692
|
-
`
|
|
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 ===
|
|
894
|
+
names.find((n) => n === DEFAULT_DATABASE) ?? (names.length === 1 ? names[0] : undefined);
|
|
700
895
|
if (!choice) {
|
|
701
|
-
throw
|
|
702
|
-
|
|
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 });
|