@dbx-tools/appkit
Node-side helpers for Databricks AppKit apps.
These helpers configure Lakebase before plugin startup and expose AppKit request context, typed plugin lookup, and Databricks SDK cancellation to backend code.
Quick Start
Section titled “Quick Start”Use the wrapper when Lakebase settings must be resolved before plugin setup:
import { lakebase, server } from "@databricks/appkit";import { appkit } from "@dbx-tools/appkit";
await appkit.createApp({ plugins: [server(), lakebase()],});Key features:
- Auto-configuration before AppKit setup, especially for Lakebase/Postgres env values that AppKit plugins read during initialization.
- Runtime-safe context access for code that may run inside an AppKit request, from a CLI, or from a background script.
- Typed plugin lookup helpers for AppKit plugins that depend on exports from sibling plugins.
- Lakebase resolution through the shared
@dbx-tools/coreconfiguration path, including local env, bundle, andapp.yamlsources. - SDK cancellation bridging from web
AbortSignalvalues into Databricks SDKContextvalues. - Lakebase cache-schema provisioning for deployments where the app identity must be granted access before persistent cache initialization.
- AppKit-global filesystem caching with user-isolated bounded L1 state, cross-user invalidation, and stable framework filesystem sources.
- An interceptor context on
createApp(interceptor?: Interceptor | Interceptor[]) that hands add-ons the computed env, AppKit lifecycle hooks (onLifecycle, using AppKit’s ownsetup:complete/server:ready/shutdownvocabulary).@dbx-tools/tunnelconsumes this surface for auxiliary startup and shutdown.
Use With Native AppKit
Section titled “Use With Native AppKit”Use native AppKit directly when your app can read its required env vars before
createApp() and does not need extra setup around plugin exports or config
sources.
Use this package when the friction is around bootstrapping and reuse:
- AppKit plugins read Lakebase/Postgres env during initialization; this package resolves and applies those values before setup.
- AppKit exposes request context inside AppKit handlers; these helpers make code safe to call from scripts, tests, and background jobs too.
- AppKit plugin instances are generic; the lookup helpers keep sibling-plugin access typed and return clear errors.
- Local CLI flags, bundle validation output, and
app.yamlvalues use the same@dbx-tools/coreconfiguration path. - AppKit’s
asUser(req)throws outsideNODE_ENV=developmentwhen a request carries no OBO token;identitymakes falling back to the service principal a configured, per-request decision instead of aNODE_ENVside effect.
Auto-Configuration Behavior
Section titled “Auto-Configuration Behavior”appkit.createApp is a drop-in wrapper around AppKit createApp with the
same config and the same typed plugin-export map. It runs
appkit.autoConfigure() first so enabled capabilities can populate
environment variables before plugin setup runs.
import { lakebase, server } from "@databricks/appkit";import { appkit } from "@dbx-tools/appkit";
await appkit.createApp({ plugins: [server(), lakebase()],});When native lakebase() or beta database() is present, auto-config resolves
Lakebase Postgres connection settings and fills missing PG* /
LAKEBASE_* variables. That avoids a startup race where a plugin reads env
before another async setup step can discover it. A database-only app resolves
env without implicitly broadening cache-schema grants; AppKit’s database plugin
owns its own pool.
Auto-configuration is conservative: existing env vars win unless a caller passes explicit options, and local-only discovery is skipped inside a Databricks App environment. This makes the same entrypoint usable in local development, Databricks Asset Bundle validation, and deployed Apps.
During local startup, a missing DATABRICKS_CONFIG_PROFILE is populated from
@dbx-tools/auth’s default-profile resolution before bundle validation runs.
Deployed Apps retain native AppKit authentication and do not receive a profile.
Boot-time resolution runs as the service principal before any plugin exists, so
it sits outside AppKit’s interceptor chain. It carries its own timeout, and
appkit.autoConfigure() accepts an AbortSignal when a caller wants to
cancel it earlier.
Use the lower-level functions when you need to inspect or customize the result:
import { lakebaseResolver } from "@dbx-tools/appkit";
const resolved = await lakebaseResolver.resolveLakebaseConnection({ endpoint: process.env.LAKEBASE_ENDPOINT, autoCreate: false,});
lakebaseResolver.applyLakebaseToEnv(resolved);applyLakebaseEnv() does both steps and applies the COMPLETE set a Postgres pool
needs, PGUSER included — it is what auto-configuration itself calls, so a caller
outside createApp gets the same env rather than a hand-rolled subset:
const { resolved, user } = await lakebaseResolver.applyLakebaseEnv({ autoCreate: false });Reach for it when something other than the lakebase() plugin needs a working
pool. AppKit’s PERSISTENT cache is the common case: it builds its own pool from
env, and createLakebasePool() throws unless LAKEBASE_ENDPOINT, PGHOST,
PGDATABASE, and a username are all present — while a Databricks App postgres
resource binding supplies only the first. Miss one and the cache degrades silently
to in-memory. @dbx-tools/tunnel’s OTP gate calls this for exactly that
reason.
Configuration
Section titled “Configuration”appkit.createApp() accepts everything AppKit’s createApp does, plus:
| Option | Type | Default | Description |
|---|---|---|---|
autoConfigure |
"provision" | "env" | false |
"provision" |
What to run before AppKit boots. "provision" resolves the Lakebase connection into process.env and grants the AppKit cache schema; "env" resolves the connection only; false skips auto-configuration. Omit it to gate resolution on native lakebase or database; implicit database-only resolution does not grant the cache schema. |
interceptor |
Interceptor | Interceptor[] |
none | One or many callbacks handed an InterceptorContext after auto-config computes the env and before AppKit boots. The context carries the resolved env and AppKit lifecycle registration through onLifecycle. |
Set autoConfigure explicitly on an app that registers no lakebase() plugin but
still wants Lakebase-backed cache persistence. The injected AppKit storage is
memory-first: warm hits avoid persistent reads and last_accessed writes, while
mutations are coalesced and written behind without blocking the request. When a
Lakebase pool is available it is the persistent level. Otherwise the cache uses
the portable project-scoped disk level from @dbx-tools/cache. "env" is the
right mode inside a deployed app, since the app service principal cannot grant on
the cache schema anyway.
createApp() installs this storage by default when AppKit caching is enabled and
the caller did not provide cache.storage. A caller-provided storage is preserved.
Native AppKit composition can create the same adapter directly:
import { cacheStorage } from "@dbx-tools/appkit";
const storage = await cacheStorage.createAppKitCacheStorage();AppKit closes injected storage during shutdown. Native composition should call
close() when it owns the storage directly; that drains tracked write-behind
work before disconnecting the persistent levels.
Filesystem Cache Plugin
Section titled “Filesystem Cache Plugin”Register filesCache() when provider-backed filesystems should share one
AppKit-lifecycle cache:
import { filesCache } from "@dbx-tools/appkit/files-cache";import { server } from "@databricks/appkit";import { appkit } from "@dbx-tools/appkit";
await appkit.createApp({ plugins: [server(), filesCache()],});The plugin exposes forScope({ host, workspaceId?, userKey, ttlMs? }), which
returns the FileSystemCache consumed by @dbx-tools/shared-fs fs.cache().
Values are isolated by host, optional workspace id, and attributed user. A
batched invalidation clears matching keys across active user scopes without
sharing authorization-sensitive values.
forFileSystem(scope, { paths, key? }, load) retains one framework filesystem
source for the same user and actual mounted paths. key separates framework
instances that use different policies over the same paths. This gives frameworks
such as Mastra a stable source identity across page refreshes and turns without
introducing a second parsed catalogue cache.
Values and retained filesystem sources use bounded process-local Cacheable stores. Nothing is written to Lakebase or another persistent level. App restarts and replica changes begin with a cold filesystem cache.
This cache is intentionally separate from the application cache adapter.
FilesCachePlugin remains the single app-lifetime owner for local-only filesystem
values rather than creating one cache per workspace or persisting
authorization-sensitive filesystem state.
lakebaseResolver.resolveLakebaseConnection() accepts:
| Option | Type | Default | Description |
|---|---|---|---|
endpoint |
string |
LAKEBASE_ENDPOINT |
Any address pgaddress.parseAddress() understands: resource path, Postgres URI, hostname, or bare project id. |
project |
string |
discovered | Lakebase project id. Resolved from the workspace when unset. |
branch |
string |
project default | Branch id within the project. |
database |
string |
PGDATABASE, else databricks_postgres |
Postgres database name. |
host |
string |
PGHOST, else the endpoint’s host |
Postgres hostname. |
port |
number |
PGPORT, else 5432 |
Postgres port. |
sslMode |
"require" | "disable" | "prefer" |
PGSSLMODE, else require |
Postgres TLS mode. |
autoCreate |
string | false |
slug of the package name | Project id to create when the workspace has none. false fails instead of creating. |
Environment variables read during resolution:
| Variable | Description |
|---|---|
LAKEBASE_ENDPOINT |
Endpoint resource path, Postgres URI, hostname, or project id. |
PGHOST |
Postgres hostname. Skips the endpoint lookup when set with PGDATABASE and LAKEBASE_ENDPOINT. |
PGDATABASE |
Postgres database name. |
PGPORT |
Postgres port. A value outside 1-65535 fails with a ValidationError. |
PGSSLMODE |
require, disable, or prefer. Any other value fails with a ValidationError. |
PGUSER |
Connecting role. Filled from the workspace identity when unset. |
Resolved values are written back to process.env by
lakebaseResolver.applyLakebaseToEnv(), which never overwrites a variable that
is already set. That function covers everything except PGUSER, which needs an
await; lakebaseResolver.applyLakebaseEnv() resolves and applies the full set.
Resolve Local And Bundle Config
Section titled “Resolve Local And Bundle Config”Configuration is owned by @dbx-tools/core, not re-exported by this package.
Its default precedence is constant config, process env, .env, bundle
config.env, then app.yaml / app.yml.
import { configUtils } from "@dbx-tools/core";
const warehouseId = configUtils.resolveValue("DATABRICKS_WAREHOUSE_ID", { data: { DATABRICKS_WAREHOUSE_ID: flags.warehouse },});Use this in CLIs and setup scripts that should behave the same locally and in a
Databricks App deployment. configUtils.bundleFile() exposes validated bundle
output, while configUtils.appFile() exposes parsed App YAML. Both cache successful,
missing, and invalid results for the process lifetime.
Parse Lakebase Addresses
Section titled “Parse Lakebase Addresses”pgaddress.parseAddress() accepts resource paths, Postgres URLs, bare
Lakebase hosts, and partial inputs. It gives the resolver a common shape without
requiring users to remember one canonical format.
import { pgaddress } from "@dbx-tools/appkit";
pgaddress.parseAddress( "postgresql://user@ep-foo.database.azuredatabricks.net/databricks_postgres?sslmode=require",);pgaddress.parseResourcePath() is useful when you specifically expect a
projects/<id>/branches/<id>/endpoints/<id> value.
Use AppKit Execution Context Safely
Section titled “Use AppKit Execution Context Safely”appkit.tryGetExecutionContext() returns the active AppKit request context when
code is running under AppKit, and undefined elsewhere. That lets libraries
preserve OBO auth in apps while still working from scripts.
import { appkit } from "@dbx-tools/appkit";import { createWorkspaceClient } from "@databricks/appkit";
const client = appkit.tryGetExecutionContext()?.client ?? createWorkspaceClient();appkit.ensureInitialized() lazily initializes AppKit runtime state before
context lookup in code paths that may run early.
Adapt Databricks SDK Cancellation
Section titled “Adapt Databricks SDK Cancellation”Databricks SDK calls accept a Context. Many app and web APIs use
AbortSignal. databricks.toContext() bridges the two.
import { databricks } from "@dbx-tools/appkit";
await client.apiClient.request( { path: "/api/2.0/serving-endpoints", method: "GET", headers: new Headers(), raw: false, }, databricks.toContext(request.signal),);Look Up Sibling Plugins
Section titled “Look Up Sibling Plugins”AppKit’s plugin map is intentionally generic. pluginRegistry.data(),
pluginRegistry.instance(), and pluginRegistry.require() keep lookups typed and produce better
errors when a required plugin is missing.
import { lakebase } from "@databricks/appkit";import { pluginRegistry } from "@dbx-tools/appkit";
const lake = pluginRegistry.instance(this.context, lakebase);const pool = lake?.exports().pool;
const required = pluginRegistry.require(this.context, lakebase, "my-plugin").exports();Use this in AppKit plugins that depend on sibling plugin exports but should not
hard-code registered names or casts at every call site. A missing required
plugin throws AppKit’s ConfigurationError, naming both the caller and the
plugin to register.
Supervise AppKit Subprocesses
Section titled “Supervise AppKit Subprocesses”AppKitChildProcess starts a long-running sibling process through
@dbx-tools/core/exec and owns its shutdown escalation. Its spawn tuple accepts
core exec options, including line handlers and optional output capture.
import { AppKitChildProcess } from "@dbx-tools/appkit/child-process";
const worker = new AppKitChildProcess( [ "bun", ["run", "worker.ts"], { stdout: { onLine: (line) => logger.info(line), capture: false }, stderr: { onLine: (line) => logger.error(line), capture: false }, }, ], { healthCheck: async ({ signal }) => { try { return (await fetch("http://127.0.0.1:3000/health", { signal })).ok; } catch (error) { if (signal.aborted) throw error; return false; } }, healthCheckTimeoutMs: 30_000, },);
await worker.start();this.context.onLifecycle("shutdown", () => worker.shutdown());When configured, start() polls healthCheck until it returns true and
rejects when the process exits, the check throws, or the readiness timeout
expires. The callback receives the live process, attempt number, and an
AbortSignal suitable for fetch.
Standalone foreground entrypoints can await run() instead of wiring
start(), process signal handlers, process completion, and shutdown()
separately. It shuts down the tree before re-raising SIGINT, SIGTERM, or
SIGHUP. SIGKILL cannot be intercepted by Node.
shutdown() is idempotent. It aborts an active readiness check, then delegates
tree termination to @dbx-tools/core: every discovered process receives
SIGTERM, surviving and newly discovered descendants receive SIGKILL, and
shutdown fails if anything remains after the configured timeouts. Pass a
non-SIGKILL signal to run only that graceful tree phase, or pass SIGKILL to
skip directly to forced polling.
Provision Lakebase Cache Schema
Section titled “Provision Lakebase Cache Schema”provision.provisionCacheSchema() grants the AppKit cache schema in Lakebase to
the Postgres role that will run the app. Use it after Lakebase connection env has
been resolved and before AppKit initializes its persistent cache.
import { provision } from "@dbx-tools/appkit";
await provision.provisionCacheSchema("app-service-principal@databricks.com");Pass a second argument to report progress on your own logger. The grants are skipped inside a Databricks App, and any failure is logged rather than thrown so a degraded cache never blocks startup.
Choose The Request Identity
Section titled “Choose The Request Identity”AppKit gives a plugin two identities: the ambient service context (the app’s own
service principal) and a per-request user context entered with asUser(req),
which authenticates on-behalf-of (OBO) using the token the Databricks front door
forwards on x-forwarded-access-token. When that header is absent, AppKit’s
behaviour depends entirely on NODE_ENV:
NODE_ENV |
asUser(req) with no x-forwarded-access-token |
|---|---|
development |
logs a warning, silently runs as the service principal |
| anything else | throws AuthenticationError: Missing user token |
That throw is right for an app behind the front door, where a missing token means
something is broken. It is fatal for an app whose traffic legitimately arrives
without one - a public tunnel where callers authenticate by email code
(@dbx-tools/tunnel), or a bot channel that validates its
own inbound JWT (POST /api/teams/messages). Those apps must not run with
NODE_ENV=development just to get the fallback, since that flag also relaxes
secure cookies and unlocks other dev-only escape hatches.
identity is that decision, made explicit:
import { identity } from "@dbx-tools/appkit";
const mode = identity.resolveIdentityMode(config.identity, "MY_APP_IDENTITY");
// One call decides whether this request enters `asUser`.const scoped = identity.useServicePrincipal(mode, req) ? this : this.asUser(req);const rows = await scoped.executeQuery(sql);| Mode | Behaviour |
|---|---|
user (default) |
Always OBO. Per-user attribution and per-user Genie / Unity Catalog filters. |
service-principal |
Always the app’s own identity. Needs no OBO scopes and serves any caller. |
auto |
OBO when the request carries a usable token, service principal otherwise. |
auto decides per REQUEST, not per boot, because one container can serve both
doors at once - the tunnel gate and the platform front door share a port. A
boot-time flag would have to be wrong for one of them. An unrecognized configured
value throws a ConfigurationError rather than falling back, because a typo
("obo", "sp") would otherwise keep serving the very error the option was set
to avoid.
Running as the service principal does not change WHO the request belongs to. The
caller still arrives on x-forwarded-user / x-forwarded-email (read them with
identity.requestUserId() / requestUserEmail()), so memory threads, cache
namespaces, and trace attribution stay per-user - only the Databricks credential
is shared. @dbx-tools/appkit-mastra exposes this as its genieIdentity option.
Modules
Section titled “Modules”| Module | Responsibility |
|---|---|
appkit |
createApp() / autoConfigure(), plus execution context lookup and initialization. |
lakebaseResolver |
Lakebase connection discovery, default picking, optional auto-create, and env application (applyLakebaseEnv() for the full set a pool needs). |
pgaddress |
Permissive Lakebase/Postgres address parser. |
bundle |
Bundle and Databricks App environment compatibility helpers. |
databricks |
Databricks SDK context cancellation adapters. |
interceptor |
Lifecycle bridging, teardown callbacks, and process-wide child supervision. |
plugin |
Typed AppKit plugin data, instance, and required-instance lookup. |
toolkit |
Shared AppKit toolkit filtering, naming, and entry construction. |
provision |
Cache schema provisioning helpers. |
identity |
OBO-vs-service-principal request identity: modes, resolution, and the forwarded headers. |
The shell-facing wrapper for auto-config is
@dbx-tools/cli/appkit. Higher-level agent composition
is in @dbx-tools/appkit-mastra.