@dbx-tools/appkit 0.6.58 → 0.6.60

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/package.json CHANGED
@@ -28,8 +28,8 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@databricks/sdk-experimental": "^0.17.0",
31
- "@dbx-tools/core": "0.6.58",
32
- "@dbx-tools/shared-core": "0.6.58",
31
+ "@dbx-tools/core": "0.6.60",
32
+ "@dbx-tools/shared-core": "0.6.60",
33
33
  "yaml": "^2.9.0",
34
34
  "zod": "4.3.6"
35
35
  },
@@ -47,7 +47,7 @@
47
47
  "./package.json": "./package.json"
48
48
  }
49
49
  },
50
- "version": "0.6.58",
50
+ "version": "0.6.60",
51
51
  "types": "./lib/index.d.ts",
52
52
  "type": "module",
53
53
  "exports": {
package/src/create-app.ts CHANGED
@@ -21,17 +21,20 @@
21
21
  * @module
22
22
  */
23
23
 
24
- import { createApp as appkitCreateApp, getUsernameWithApiLookup } from "@databricks/appkit";
24
+ import { createApp as appkitCreateApp } from "@databricks/appkit";
25
25
  // AppKit's root barrel re-exports `PluginData` but not `PluginMap`; the package
26
26
  // publishes this subpath for exactly that type.
27
27
  import type { PluginMap } from "@databricks/appkit/dist/shared/src/plugin";
28
28
  import { async, log } from "@dbx-tools/shared-core";
29
29
 
30
30
  import {
31
- applyLakebaseToEnv,
32
- resolveLakebaseConnection,
33
- type LakebaseConnection,
34
- } from "./lakebase-resolver.ts";
31
+ createInterceptorContext,
32
+ type Interceptor,
33
+ type InterceptorRuntime,
34
+ lifecycleBridge,
35
+ type ResolvedAppEnv,
36
+ } from "./interceptor.ts";
37
+ import { applyLakebaseEnv, type LakebaseConnection } from "./lakebase-resolver.ts";
35
38
  import { provisionCacheSchema } from "./provision.ts";
36
39
 
37
40
  type AppKitCreateAppConfig = NonNullable<Parameters<typeof appkitCreateApp>[0]>;
@@ -67,6 +70,13 @@ export type CreateAppConfig<T extends AppKitPlugins = AppKitPlugins> = Omit<
67
70
  onPluginsReady?: (appkit: PluginMap<T>) => void | Promise<void>;
68
71
  /** Auto-configuration to run before AppKit boots. Defaults to `"provision"`. */
69
72
  autoConfigure?: AutoConfigureMode | false;
73
+ /**
74
+ * One or many {@link Interceptor}s handed an {@link InterceptorContext} once
75
+ * auto-configuration has computed the env. Each receives the resolved env, an
76
+ * AppKit-lifecycle hook, and `bindProcess` for concurrently-style supervision -
77
+ * see `./interceptor`. The tunnel is the primary consumer.
78
+ */
79
+ interceptor?: Interceptor | Interceptor[];
70
80
  };
71
81
 
72
82
  const logger = log.logger("create-app");
@@ -107,7 +117,7 @@ function usesPlugin<T extends AppKitPlugins>(
107
117
  export async function autoConfigure<T extends AppKitPlugins>(
108
118
  config?: CreateAppConfig<T>,
109
119
  signal?: AbortSignal,
110
- ): Promise<void> {
120
+ ): Promise<LakebaseConnection | undefined> {
111
121
  const mode = config?.autoConfigure ?? DEFAULT_AUTO_CONFIGURE;
112
122
  const explicit = config?.autoConfigure !== undefined;
113
123
  const lakebasePluginPresent = usesPlugin(config, LAKEBASE_PLUGIN);
@@ -118,7 +128,7 @@ export async function autoConfigure<T extends AppKitPlugins>(
118
128
  provisioned: false,
119
129
  skippedReason: mode === false ? "disabled" : "no lakebase plugin",
120
130
  });
121
- return;
131
+ return undefined;
122
132
  }
123
133
 
124
134
  const controller = new AbortController();
@@ -126,24 +136,22 @@ export async function autoConfigure<T extends AppKitPlugins>(
126
136
  async.tieAbortSignal(controller, AbortSignal.timeout(AUTO_CONFIGURE_TIMEOUT_MS));
127
137
 
128
138
  const provision = mode === "provision";
129
- await autoConfigureLakebase(provision, controller.signal);
139
+ const resolved = await autoConfigureLakebase(provision, controller.signal);
130
140
  logger.info("ready", { autoConfigure: mode, lakebasePluginPresent, provisioned: provision });
141
+ return resolved;
131
142
  }
132
143
 
133
144
  /**
134
145
  * Resolve Lakebase Postgres connection info, write the resolved values to
135
146
  * `process.env`, and return the record. Used by {@link autoConfigure}; call
136
- * {@link resolveLakebaseConnection} and {@link applyLakebaseToEnv} directly when
137
- * finer control is needed.
147
+ * {@link applyLakebaseEnv} directly when finer control is needed (a different
148
+ * `autoCreate` policy, or a caller that wants the env without booting AppKit).
138
149
  */
139
150
  async function autoConfigureLakebase(
140
151
  provision: boolean,
141
152
  signal: AbortSignal,
142
153
  ): Promise<LakebaseConnection> {
143
- const resolved = await resolveLakebaseConnection(undefined, signal);
144
- applyLakebaseToEnv(resolved);
145
- const user = await getUsernameWithApiLookup({});
146
- if (user) process.env.PGUSER ??= user;
154
+ const { resolved, user } = await applyLakebaseEnv(undefined, signal);
147
155
  logger.info("env updated", { ...redactLakebaseConnection(resolved), user });
148
156
  if (provision) {
149
157
  await provisionCacheSchema(user, logger);
@@ -163,10 +171,31 @@ function redactLakebaseConnection(resolved: LakebaseConnection): Record<string,
163
171
  };
164
172
  }
165
173
 
174
+ /** Build the {@link ResolvedAppEnv} interceptors read, from the auto-config result. */
175
+ function resolvedAppEnv(lakebase: LakebaseConnection | undefined): ResolvedAppEnv {
176
+ return {
177
+ ...(lakebase ? { lakebase } : {}),
178
+ ...(process.env.DATABRICKS_HOST ? { databricksHost: process.env.DATABRICKS_HOST } : {}),
179
+ };
180
+ }
181
+
182
+ /** Normalize the `interceptor?: Interceptor | Interceptor[]` option to an array. */
183
+ function interceptorList(interceptor: CreateAppConfig["interceptor"]): Interceptor[] {
184
+ if (!interceptor) return [];
185
+ return Array.isArray(interceptor) ? interceptor : [interceptor];
186
+ }
187
+
166
188
  /**
167
189
  * Auto-configuring drop-in for AppKit's `createApp`: same config, same typed
168
190
  * plugin-export map, with {@link autoConfigure} run first.
169
191
  *
192
+ * When {@link CreateAppConfig.interceptor}s are given, each is invoked with an
193
+ * {@link InterceptorContext} AFTER auto-configuration computes the env and BEFORE
194
+ * AppKit boots - so an interceptor can read the resolved connection, register
195
+ * lifecycle handlers, and `bindProcess` a child. A hidden {@link lifecycleBridge}
196
+ * plugin is injected so those `onLifecycle` handlers fire on the genuine AppKit
197
+ * events; it has no exports, so the returned {@link PluginMap} is unchanged.
198
+ *
170
199
  * @example
171
200
  * import { createApp } from "@dbx-tools/appkit";
172
201
  * import { lakebase, server } from "@databricks/appkit";
@@ -176,8 +205,26 @@ function redactLakebaseConnection(resolved: LakebaseConnection): Record<string,
176
205
  export async function createApp<T extends AppKitPlugins>(
177
206
  config?: CreateAppConfig<T>,
178
207
  ): Promise<PluginMap<T>> {
179
- await autoConfigure(config);
208
+ const lakebase = await autoConfigure(config);
180
209
  const appConfig = { ...config };
181
210
  delete appConfig.autoConfigure;
211
+ delete appConfig.interceptor;
212
+
213
+ const interceptors = interceptorList(config?.interceptor);
214
+ if (interceptors.length === 0) {
215
+ return appkitCreateApp<T>(appConfig);
216
+ }
217
+
218
+ // Build the context from the computed env, run each interceptor (they register
219
+ // lifecycle handlers + bind processes), then inject the bridge that relays the
220
+ // REAL AppKit lifecycle into `runtime.emitLifecycle` during its `setup()`.
221
+ const runtime: InterceptorRuntime = createInterceptorContext(resolvedAppEnv(lakebase));
222
+ for (const interceptor of interceptors) {
223
+ await interceptor(runtime.context);
224
+ }
225
+ // Append the bridge to the plugins tuple. It is hidden and exports nothing, so
226
+ // the returned map still matches `PluginMap<T>`; the cast (through `unknown`) is
227
+ // only because appending widens the tuple type beyond `T`.
228
+ appConfig.plugins = [...(appConfig.plugins ?? []), lifecycleBridge({ runtime })] as unknown as T;
182
229
  return appkitCreateApp<T>(appConfig);
183
230
  }
package/src/identity.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  * where a missing token means something is wrong. It is fatal for an app whose
21
21
  * traffic legitimately arrives WITHOUT one:
22
22
  *
23
- * - a public tunnel (`@dbx-tools/cli-tunnel`), where callers authenticate by
23
+ * - a public tunnel (`@dbx-tools/tunnel`), where callers authenticate by
24
24
  * email OTP and no OBO token exists to forward - the gate can prove WHO the
25
25
  * caller is, but it cannot mint a Databricks credential for them;
26
26
  * - any reverse proxy, webhook, or bot channel (`POST /api/teams/messages`)
@@ -41,7 +41,7 @@
41
41
  *
42
42
  * `"auto"` decides per REQUEST, not per boot, because a single container serves
43
43
  * both doors at once - the tunnel gate and the platform front door share a port
44
- * (see `@dbx-tools/cli-tunnel`). A boot-time flag would have to be wrong for one
44
+ * (see `@dbx-tools/tunnel`). A boot-time flag would have to be wrong for one
45
45
  * of them.
46
46
  *
47
47
  * What the service principal does NOT change is WHO the request belongs to. The
@@ -0,0 +1,261 @@
1
+ /**
2
+ * The `createApp` INTERCEPTOR context: an in-process handle an app hands to
3
+ * add-ons (the tunnel, chiefly) so they can read the env auto-configuration
4
+ * computed, hook AppKit's lifecycle, and supervise sibling child processes as one
5
+ * unit - concurrently-style, where any death takes the whole set down.
6
+ *
7
+ * An interceptor is a plain function `(ctx) => void | Promise<void>` passed to
8
+ * {@link CreateAppConfig.interceptor} ("one or many"). {@link createApp} runs each
9
+ * one AFTER auto-configuration has populated `process.env` but as part of booting
10
+ * the app, so an interceptor sees the resolved connection and can bind processes
11
+ * before or during setup.
12
+ *
13
+ * The names here mirror AppKit's own vocabulary rather than inventing parallel
14
+ * ones: {@link LifecycleEvent} and {@link InterceptorContext.onLifecycle} are the
15
+ * exact shape of `PluginContext.onLifecycle` (`setup:complete` / `server:ready` /
16
+ * `shutdown`). The bridge that makes that hook reachable from OUTSIDE a plugin -
17
+ * where interceptors run - is {@link lifecycleBridge}, a tiny internal plugin
18
+ * {@link createApp} injects to capture `this.context` and relay its events.
19
+ *
20
+ * @module
21
+ */
22
+
23
+ import type { ChildProcess } from "node:child_process";
24
+ import { Plugin, toPlugin, type BasePluginConfig, type PluginManifest } from "@databricks/appkit";
25
+ import { log } from "@dbx-tools/shared-core";
26
+ import type { LakebaseConnection } from "./lakebase-resolver.ts";
27
+
28
+ const logger = log.logger("interceptor");
29
+
30
+ /**
31
+ * AppKit's plugin-lifecycle events, verbatim from `PluginContext`
32
+ * (`node_modules/@databricks/appkit/.../core/plugin-context.d.ts`). Re-declared
33
+ * structurally rather than imported because `PluginContext` is not in the
34
+ * package's `exports` map (the same reason {@link PluginContextLike} exists in
35
+ * `./plugin`).
36
+ *
37
+ * - `setup:complete` - every plugin's `setup()` has resolved.
38
+ * - `server:ready` - the `server()` plugin is listening (never fires for a
39
+ * serverless app, e.g. the tunnel gate).
40
+ * - `shutdown` - the app is tearing down.
41
+ */
42
+ export type LifecycleEvent = "setup:complete" | "server:ready" | "shutdown";
43
+
44
+ /** A lifecycle subscriber. Errors are logged, not propagated (matches AppKit). */
45
+ export type LifecycleHandler = () => void | Promise<void>;
46
+
47
+ /**
48
+ * The env auto-configuration resolved before boot, exposed to interceptors so
49
+ * they read COMPUTED values instead of re-reading `process.env` themselves.
50
+ *
51
+ * `lakebase` is the resolved Postgres connection when Lakebase auto-config ran
52
+ * (see `create-app`'s `autoConfigure`), else `undefined`. `databricksHost` is the
53
+ * workspace host as resolved into the environment (`DATABRICKS_HOST`), which the
54
+ * tunnel interceptor both reads and, when it must, sets.
55
+ */
56
+ export interface ResolvedAppEnv {
57
+ /** Resolved Lakebase connection, when auto-config resolved one. */
58
+ readonly lakebase?: LakebaseConnection;
59
+ /** Resolved Databricks workspace host (`DATABRICKS_HOST`), when known. */
60
+ readonly databricksHost?: string;
61
+ }
62
+
63
+ /**
64
+ * A process {@link InterceptorContext.bindProcess} can supervise. A bare
65
+ * `node:child_process` `ChildProcess` (e.g. portr) satisfies this, and so does
66
+ * `@dbx-tools/core`'s `spawn()` result (a `ChildProcessResult` IS a
67
+ * `ChildProcess`) - one code path handles both.
68
+ */
69
+ export type BindableProcess = Pick<ChildProcess, "pid" | "kill" | "killed" | "once">;
70
+
71
+ /**
72
+ * The handle passed to each interceptor.
73
+ *
74
+ * @example
75
+ * import { createApp } from "@dbx-tools/appkit";
76
+ *
77
+ * await createApp({
78
+ * plugins: [server()],
79
+ * interceptor: (ctx) => {
80
+ * const portr = spawnPortr(ctx.env.databricksHost);
81
+ * ctx.bindProcess(portr); // app <-> portr live/die together
82
+ * ctx.onLifecycle("shutdown", () => portr.kill("SIGTERM"));
83
+ * },
84
+ * });
85
+ */
86
+ export interface InterceptorContext {
87
+ /** The env auto-configuration computed before boot. */
88
+ readonly env: ResolvedAppEnv;
89
+ /**
90
+ * Subscribe to an AppKit lifecycle event. Mirrors `PluginContext.onLifecycle`;
91
+ * the injected {@link lifecycleBridge} relays the real events here once the app
92
+ * boots. A handler registered for an event that already fired is NOT called
93
+ * retroactively (same semantics as AppKit).
94
+ */
95
+ onLifecycle(event: LifecycleEvent, fn: LifecycleHandler): void;
96
+ /**
97
+ * Broadcast a termination signal from the main app to every bound process.
98
+ * Called automatically when this process receives `SIGINT`/`SIGTERM`/`SIGHUP`;
99
+ * exposed so an interceptor can trigger teardown itself.
100
+ */
101
+ broadcastSignal(signal: NodeJS.Signals): void;
102
+ /**
103
+ * Supervise a child process alongside the app, concurrently-style: signals pass
104
+ * through, and if EITHER the child or the app dies the whole set comes down.
105
+ * Generalizes the tunnel's old hand-rolled `superviseExit`. Safe to call for
106
+ * several children; teardown is idempotent.
107
+ */
108
+ bindProcess(child: BindableProcess): void;
109
+ }
110
+
111
+ /** A single interceptor, or several. The `createApp` `interceptor?:` option. */
112
+ export type Interceptor = (ctx: InterceptorContext) => void | Promise<void>;
113
+
114
+ /** How long bound children get to exit on `SIGTERM` before the app force-exits. */
115
+ const TEARDOWN_GRACE_MS = 3000;
116
+
117
+ /** The signals that trigger teardown when the MAIN process receives them. */
118
+ const TEARDOWN_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
119
+
120
+ /**
121
+ * The mutable machinery behind an {@link InterceptorContext}. Split out so
122
+ * {@link createInterceptorContext} can hand the public context to interceptors
123
+ * while `create-app` retains the `emitLifecycle` side-channel the bridge drives.
124
+ */
125
+ export interface InterceptorRuntime {
126
+ /** The context handed to each interceptor. */
127
+ readonly context: InterceptorContext;
128
+ /** Fire a lifecycle event to every subscriber (called by {@link lifecycleBridge}). */
129
+ emitLifecycle(event: LifecycleEvent): Promise<void>;
130
+ }
131
+
132
+ /**
133
+ * Build an {@link InterceptorContext} + its {@link InterceptorRuntime}.
134
+ *
135
+ * Teardown is the generalized `superviseExit`: the first child exit or main-process
136
+ * termination signal flips a one-shot guard, `SIGTERM`s the other bound children,
137
+ * then `process.exit`s after {@link TEARDOWN_GRACE_MS} (an `unref`'d timer, so it
138
+ * never itself holds the loop open). The process-signal listeners are installed
139
+ * lazily on the first `bindProcess` so an app that binds nothing is untouched.
140
+ */
141
+ export function createInterceptorContext(env: ResolvedAppEnv): InterceptorRuntime {
142
+ const handlers = new Map<LifecycleEvent, LifecycleHandler[]>();
143
+ const children = new Set<BindableProcess>();
144
+ let shuttingDown = false;
145
+ let signalsBound = false;
146
+
147
+ const teardown = (code: number): void => {
148
+ if (shuttingDown) return;
149
+ shuttingDown = true;
150
+ for (const child of children) {
151
+ if (!child.killed) child.kill("SIGTERM");
152
+ }
153
+ setTimeout(() => process.exit(code), TEARDOWN_GRACE_MS).unref();
154
+ };
155
+
156
+ const broadcastSignal = (signal: NodeJS.Signals): void => {
157
+ for (const child of children) {
158
+ if (!child.killed) child.kill(signal);
159
+ }
160
+ teardown(0);
161
+ };
162
+
163
+ const bindProcessSignals = (): void => {
164
+ if (signalsBound) return;
165
+ signalsBound = true;
166
+ for (const signal of TEARDOWN_SIGNALS) {
167
+ process.on(signal, () => broadcastSignal(signal));
168
+ }
169
+ };
170
+
171
+ const bindProcess = (child: BindableProcess): void => {
172
+ bindProcessSignals();
173
+ children.add(child);
174
+ child.once("exit", (code) => {
175
+ logger.warn("bound process exited; tearing down the app", { code });
176
+ teardown(typeof code === "number" ? code : 1);
177
+ });
178
+ };
179
+
180
+ const onLifecycle = (event: LifecycleEvent, fn: LifecycleHandler): void => {
181
+ const list = handlers.get(event);
182
+ if (list) list.push(fn);
183
+ else handlers.set(event, [fn]);
184
+ };
185
+
186
+ const emitLifecycle = async (event: LifecycleEvent): Promise<void> => {
187
+ const list = handlers.get(event);
188
+ if (!list) return;
189
+ for (const fn of list) {
190
+ try {
191
+ await fn();
192
+ } catch (error) {
193
+ // Match AppKit: a failing lifecycle handler is logged, never fatal, and
194
+ // never blocks its siblings.
195
+ logger.warn("lifecycle handler failed", { event, error });
196
+ }
197
+ }
198
+ };
199
+
200
+ const context: InterceptorContext = {
201
+ env,
202
+ onLifecycle,
203
+ broadcastSignal,
204
+ bindProcess,
205
+ };
206
+
207
+ return { context, emitLifecycle };
208
+ }
209
+
210
+ /** Config for {@link LifecycleBridgePlugin}: the sink its captured events flow to. */
211
+ interface LifecycleBridgeConfig extends BasePluginConfig {
212
+ /** The runtime whose `emitLifecycle` receives the real AppKit events. */
213
+ runtime?: InterceptorRuntime;
214
+ }
215
+
216
+ /**
217
+ * Structural shape of the `PluginContext.onLifecycle` we bridge. Mirrors only the
218
+ * one method we touch (like {@link PluginContextLike} in `./plugin`), since
219
+ * AppKit's `PluginContext` is not importable from the package's `exports`.
220
+ */
221
+ interface LifecycleContextLike {
222
+ onLifecycle(event: LifecycleEvent, fn: LifecycleHandler): void;
223
+ }
224
+
225
+ function hasOnLifecycle(context: unknown): context is LifecycleContextLike {
226
+ return typeof (context as LifecycleContextLike | undefined)?.onLifecycle === "function";
227
+ }
228
+
229
+ /**
230
+ * The internal plugin {@link createApp} injects to make AppKit's lifecycle
231
+ * reachable from interceptor code. On `setup()` it reads its own `this.context`
232
+ * (the real `PluginContext`) and forwards each {@link LifecycleEvent} to the
233
+ * interceptor runtime, so `ctx.onLifecycle(...)` handlers fire on the genuine
234
+ * events. It owns no routes, config surface, or exports.
235
+ */
236
+ export class LifecycleBridgePlugin extends Plugin<LifecycleBridgeConfig> {
237
+ static manifest = {
238
+ name: "dbxToolsLifecycleBridge",
239
+ displayName: "Lifecycle Bridge",
240
+ description: "Relays AppKit lifecycle events to the createApp interceptor context.",
241
+ stability: "beta",
242
+ hidden: true,
243
+ resources: { required: [], optional: [] },
244
+ } satisfies PluginManifest<"dbxToolsLifecycleBridge">;
245
+
246
+ override async setup(): Promise<void> {
247
+ const runtime = this.config.runtime;
248
+ if (!runtime) return;
249
+ if (!hasOnLifecycle(this.context)) {
250
+ logger.debug("no PluginContext.onLifecycle to bridge; interceptor lifecycle events inert");
251
+ return;
252
+ }
253
+ const events: LifecycleEvent[] = ["setup:complete", "server:ready", "shutdown"];
254
+ for (const event of events) {
255
+ this.context.onLifecycle(event, () => runtime.emitLifecycle(event));
256
+ }
257
+ }
258
+ }
259
+
260
+ /** Factory for the injected {@link LifecycleBridgePlugin}. */
261
+ export const lifecycleBridge = toPlugin(LifecycleBridgePlugin);
@@ -38,6 +38,7 @@
38
38
  import {
39
39
  ConfigurationError,
40
40
  ExecutionError,
41
+ getUsernameWithApiLookup,
41
42
  getWorkspaceClient,
42
43
  ValidationError,
43
44
  } from "@databricks/appkit";
@@ -383,6 +384,9 @@ export async function resolveLakebaseConnection(
383
384
  * (which reads env directly) picks them up during its own `setup()`.
384
385
  * Existing env values are preserved; only missing keys are filled in,
385
386
  * which keeps explicit overrides authoritative.
387
+ *
388
+ * This does NOT set `PGUSER`, which needs an await - see
389
+ * {@link applyLakebaseEnv} for the complete set a Postgres pool requires.
386
390
  */
387
391
  export function applyLakebaseToEnv(resolved: LakebaseConnection): void {
388
392
  if (resolved.endpoint) process.env.LAKEBASE_ENDPOINT ??= resolved.endpoint;
@@ -392,6 +396,40 @@ export function applyLakebaseToEnv(resolved: LakebaseConnection): void {
392
396
  process.env.PGSSLMODE ??= resolved.sslMode;
393
397
  }
394
398
 
399
+ /**
400
+ * Resolve the connection AND apply every Postgres env var a Lakebase pool needs,
401
+ * returning the resolved connection plus the username that was applied.
402
+ *
403
+ * This is the whole set, which is the point of having one function for it:
404
+ * `createLakebasePool()` throws unless `LAKEBASE_ENDPOINT`, `PGHOST`,
405
+ * `PGDATABASE`, **and** a username (`PGUSER`, or `DATABRICKS_CLIENT_ID` for a
406
+ * service principal) are all present, and a Databricks App `postgres` resource
407
+ * binding supplies only the first. Anything that wants a working pool - the
408
+ * `lakebase` plugin, or AppKit's PERSISTENT cache, which quietly degrades to
409
+ * in-memory when the pool cannot be built - needs all four, so callers should not
410
+ * pair {@link applyLakebaseToEnv} with their own username lookup.
411
+ *
412
+ * `PGUSER` is applied with `??=` like the rest, so an explicitly configured value
413
+ * stays authoritative. The lookup returns `undefined` rather than throwing when it
414
+ * cannot determine a user, leaving the pool to resolve its own.
415
+ *
416
+ * @example
417
+ * import { lakebaseResolver } from "@dbx-tools/appkit";
418
+ *
419
+ * // Enough for `createLakebasePool()` to build a pool.
420
+ * const { user } = await lakebaseResolver.applyLakebaseEnv({ autoCreate: false });
421
+ */
422
+ export async function applyLakebaseEnv(
423
+ config?: LakebaseResolverInputs,
424
+ signal?: AbortSignal,
425
+ ): Promise<{ resolved: LakebaseConnection; user?: string }> {
426
+ const resolved = await resolveLakebaseConnection(config, signal);
427
+ applyLakebaseToEnv(resolved);
428
+ const user = await getUsernameWithApiLookup({});
429
+ if (user) process.env.PGUSER ??= user;
430
+ return { resolved, ...(user ? { user } : {}) };
431
+ }
432
+
395
433
  type WorkspaceClient = ReturnType<typeof getWorkspaceClient>;
396
434
 
397
435
  /** The SDK `Context` accepted by the workspace client's own copy of the SDK. */