@dbx-tools/appkit 0.3.29 → 0.3.31
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 +34 -16
- package/src/create-app.ts +104 -26
- package/src/databricks.ts +28 -3
- package/src/lakebase-resolver.ts +347 -150
- 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,14 +35,22 @@
|
|
|
35
35
|
* @module
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
|
-
import {
|
|
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
|
-
|
|
93
|
-
next_page_token
|
|
94
|
-
projects
|
|
95
|
-
branches
|
|
96
|
-
endpoints
|
|
97
|
-
databases
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
*
|
|
150
|
-
*
|
|
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
|
-
|
|
153
|
-
|
|
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 ??
|
|
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,
|
|
346
|
+
project = await pickOrCreateProject(ws, config?.autoCreate, signal);
|
|
247
347
|
}
|
|
248
348
|
|
|
249
349
|
if (!branch) {
|
|
250
|
-
branch = await pickBranch(ws, project,
|
|
350
|
+
branch = await pickBranch(ws, project, signal);
|
|
251
351
|
}
|
|
252
352
|
|
|
253
353
|
if (!endpoint) {
|
|
254
|
-
const ep = await pickEndpoint(ws, project, branch,
|
|
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
|
-
|
|
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,
|
|
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
|
-
/**
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
302
|
-
headers: new Headers({ Accept: "application/json" }),
|
|
303
|
-
raw: false,
|
|
420
|
+
issues: parsed.error.issues,
|
|
304
421
|
});
|
|
305
|
-
|
|
422
|
+
throw new ExecutionError("Lakebase API returned an unexpected response", { context: { path } });
|
|
306
423
|
}
|
|
307
424
|
|
|
308
|
-
/**
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
|
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(
|
|
335
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
442
|
-
"
|
|
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,
|
|
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
|
|
453
|
-
|
|
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
|
-
|
|
471
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
729
|
+
signal?: AbortSignal,
|
|
547
730
|
): Promise<Operation> {
|
|
548
731
|
if (op.done) {
|
|
549
732
|
if (op.error) {
|
|
550
|
-
throw
|
|
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
|
|
739
|
+
throw ExecutionError.missingData("operation name");
|
|
557
740
|
}
|
|
558
741
|
const start = Date.now();
|
|
559
|
-
|
|
560
|
-
await
|
|
561
|
-
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);
|
|
562
745
|
logger.debug("autopg: operation status", { op: opName, done: current.done });
|
|
563
746
|
if (current.done) {
|
|
564
747
|
if (current.error) {
|
|
565
|
-
throw
|
|
748
|
+
throw operationFailed(opName, current.error);
|
|
566
749
|
}
|
|
567
750
|
return current;
|
|
568
751
|
}
|
|
569
752
|
}
|
|
570
|
-
throw new
|
|
571
|
-
|
|
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
|
-
|
|
769
|
+
signal?: AbortSignal,
|
|
587
770
|
): Promise<Endpoint> {
|
|
588
771
|
const start = Date.now();
|
|
589
772
|
let last: Endpoint | null = null;
|
|
590
|
-
|
|
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
|
|
594
|
-
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) {
|
|
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
|
|
784
|
+
await pollDelay(attempt, ENDPOINT_READY_POLL_MS, signal);
|
|
602
785
|
}
|
|
603
|
-
throw new
|
|
604
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
633
|
-
|
|
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
|
-
|
|
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
|
|
656
|
-
`
|
|
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 ===
|
|
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
|
|
665
|
-
|
|
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
|
-
|
|
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
|
|
691
|
-
`
|
|
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 ===
|
|
894
|
+
names.find((n) => n === DEFAULT_DATABASE) ?? (names.length === 1 ? names[0] : undefined);
|
|
699
895
|
if (!choice) {
|
|
700
|
-
throw
|
|
701
|
-
|
|
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 });
|