@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/README.md +37 -13
- package/index.ts +3 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +3 -1
- package/lib/src/create-app.d.ts +17 -1
- package/lib/src/create-app.js +47 -13
- package/lib/src/identity.d.ts +2 -2
- package/lib/src/identity.js +3 -3
- package/lib/src/interceptor.d.ts +152 -0
- package/lib/src/interceptor.js +141 -0
- package/lib/src/lakebase-resolver.d.ts +30 -0
- package/lib/src/lakebase-resolver.js +36 -2
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/create-app.ts +62 -15
- package/src/identity.ts +2 -2
- package/src/interceptor.ts +261 -0
- package/src/lakebase-resolver.ts +38 -0
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.
|
|
32
|
-
"@dbx-tools/shared-core": "0.6.
|
|
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.
|
|
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
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
type
|
|
34
|
-
|
|
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<
|
|
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
|
|
137
|
-
*
|
|
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
|
|
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/
|
|
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/
|
|
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);
|
package/src/lakebase-resolver.ts
CHANGED
|
@@ -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. */
|