@dbx-tools/shared 0.1.18

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.
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Browser-safe entry point for `@dbx-tools/shared`. Mirrors
3
+ * the server-side {@link ./index.ts} barrel except `projectUtils` is
4
+ * absent - it imports `node:fs` / `node:child_process` / `node:path` /
5
+ * `node:util` at module load, which Vite stubs for browsers and which
6
+ * blows up at the first property access from the stub.
7
+ *
8
+ * Resolution: the package's `exports` map points the `browser`
9
+ * condition at this file. Vite (and any other browser-aware bundler
10
+ * that honors `exports.<entry>.browser`) picks it up automatically;
11
+ * Node always uses `index.ts`. Don't import `./src/project.js` from
12
+ * here, even transitively - that's the entire point of the split.
13
+ *
14
+ * Other utility namespaces are re-exported as-is. `common.ts` ships a
15
+ * pure-JS FNV-1a `fnvHash` (no `node:crypto`) that `string.ts` uses
16
+ * for slug suffixes, so the whole barrel is safe in the browser;
17
+ * `http.ts` / `log.ts` already had no node-only imports.
18
+ *
19
+ * `apiUtils` and `appkitUtils` are intentionally **not** re-exported
20
+ * here. Both import from `@databricks/appkit`, whose main barrel
21
+ * re-exports server-only typegen helpers
22
+ * (`extractServingEndpoints`, the `appKit*TypesPlugin` Vite plugins)
23
+ * that transitively load `@ast-grep/napi`'s native `.node` binary.
24
+ * Letting either land in the browser bundle drags the entire appkit
25
+ * tree (including ast-grep) into the client. They live only on
26
+ * `index.ts` (the server entry).
27
+ */
28
+ export * as commonUtils from "./src/common.js";
29
+ export * as httpUtils from "./src/http.js";
30
+ export * as logUtils from "./src/log.js";
31
+ export * as netUtils from "./src/net.browser.js";
32
+ export * as stringUtils from "./src/string.js";
package/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Server-side entry point for `@dbx-tools/shared`. Re-exports
3
+ * everything from the browser-safe {@link ./index.client.ts} barrel
4
+ * and adds the server-only namespaces:
5
+ * - `projectUtils` imports `node:fs` / `node:child_process` /
6
+ * `node:path` / `node:util`.
7
+ * - `apiUtils` / `appkitUtils` both import from `@databricks/appkit`,
8
+ * whose barrel transitively pulls in the typegen helpers and the
9
+ * `@ast-grep/napi` native binary. Keeping them out of the
10
+ * browser entry stops that whole subtree from being bundled
11
+ * for the client.
12
+ *
13
+ * Resolution: this file is the `import` / `default` target in the
14
+ * package's `exports` map. Vite (and any other browser-aware
15
+ * bundler that honors `exports.<entry>.browser`) picks
16
+ * `index.client.ts` instead, so the node-only branches never ship
17
+ * to the client. Add new browser-safe helpers to `index.client.ts`
18
+ * to keep this file as the thin server-only delta.
19
+ */
20
+
21
+ export * as appkitUtils from "./src/appkit.js";
22
+ export * as apiUtils from "./src/api.js";
23
+ export * as netUtils from "./src/net.js";
24
+ export * as projectUtils from "./src/project.js";
25
+
26
+ export * from "./index.client.js";
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "main": "./dist/index.js",
3
+ "types": "./dist/index.d.ts",
4
+ "exports": {
5
+ ".": {
6
+ "browser": {
7
+ "types": "./dist/index.client.d.ts",
8
+ "default": "./dist/index.client.js"
9
+ },
10
+ "import": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "source": {
15
+ "browser": "./index.client.ts",
16
+ "default": "./index.ts"
17
+ },
18
+ "default": {
19
+ "types": "./dist/index.d.ts",
20
+ "default": "./dist/index.js"
21
+ }
22
+ }
23
+ },
24
+ "name": "@dbx-tools/shared",
25
+ "version": "0.1.18",
26
+ "dependencies": {
27
+ "fast-deep-equal": "^3.1.3",
28
+ "lru-cache": "^11.5.1",
29
+ "zod": "^4.3.6"
30
+ },
31
+ "devDependencies": {
32
+ "@databricks/sdk-experimental": "^0.17"
33
+ },
34
+ "module": "index.ts",
35
+ "peerDependencies": {
36
+ "@databricks/appkit": "*"
37
+ },
38
+ "type": "module",
39
+ "files": [
40
+ "dist",
41
+ "index*.ts",
42
+ "src"
43
+ ],
44
+ "license": "Apache-2.0",
45
+ "homepage": "https://github.com/reggie-db/dbx-tools-appkit#readme",
46
+ "bugs": {
47
+ "url": "https://github.com/reggie-db/dbx-tools-appkit/issues"
48
+ },
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/reggie-db/dbx-tools-appkit.git",
52
+ "directory": "packages/shared"
53
+ }
54
+ }
package/src/api.ts ADDED
@@ -0,0 +1,222 @@
1
+ import type { CancellationToken, WorkspaceClient } from "@databricks/sdk-experimental";
2
+ import { Context } from "@databricks/sdk-experimental";
3
+ import { CacheManager, getExecutionContext } from "@databricks/appkit";
4
+
5
+ // Direct imports (not via the barrel). The package's NodeNext
6
+ // module resolution wants explicit `.js` extensions on relative
7
+ // imports, and reaching for `commonUtils` / `netUtils` through
8
+ // `../index.client` confused the `noEmit` typecheck with a
9
+ // missing-extension error. Direct sibling imports stay typed and
10
+ // don't risk a future cycle.
11
+ import { fnvHash, tieAbortSignal } from "./common.js";
12
+ import { joinUrl, parseUrl } from "./net.browser.js";
13
+
14
+ // ────────────────────────────────────────────────────────────────
15
+ // Constants
16
+ // ────────────────────────────────────────────────────────────────
17
+
18
+ const API_PREFIX = "/api/2.0";
19
+ type GetOrExecuteParams = Parameters<CacheManager["getOrExecute"]>;
20
+ type ApiRequestInit = RequestInit & {
21
+ cache?: {
22
+ key?: GetOrExecuteParams[0];
23
+ userKey?: GetOrExecuteParams[2];
24
+ options: GetOrExecuteParams[3];
25
+ };
26
+ workspaceClient?: WorkspaceClient;
27
+ };
28
+
29
+ /**
30
+ * Build the absolute `URL` for a Databricks workspace REST endpoint
31
+ * without issuing a request. Mirrors {@link fetchApi}'s path handling
32
+ * (single string or array of segments, leading `/api/2.0` stripped)
33
+ * so callers can construct request URLs that match what `fetchApi`
34
+ * would have used. Resolves the host from the supplied
35
+ * `WorkspaceClient` or, when omitted, from the active
36
+ * `getExecutionContext().client`.
37
+ */
38
+ export async function apiUrl(
39
+ path: string[] | string,
40
+ workspaceClient?: WorkspaceClient,
41
+ ): Promise<URL> {
42
+ let joinedPath = joinUrl(path);
43
+ if (joinedPath === API_PREFIX || joinedPath.startsWith(API_PREFIX + "/")) {
44
+ joinedPath = joinedPath.slice(API_PREFIX.length);
45
+ }
46
+ if (!joinedPath) {
47
+ throw new Error(`Invalid path: ${path}`);
48
+ }
49
+ const client = workspaceClient ?? getExecutionContext().client;
50
+ const config = client.config;
51
+ const host = await config.getHost();
52
+ const url = parseUrl(host, API_PREFIX, joinedPath)!;
53
+ return url;
54
+ }
55
+
56
+ /**
57
+ * Issue an authenticated request against a Databricks workspace REST
58
+ * endpoint, resolving the host from the supplied `WorkspaceClient`
59
+ * and stamping the OAuth/PAT auth header in for you. The response
60
+ * body is returned parsed as JSON.
61
+ *
62
+ * `path` may be a single string or an array of segments. A leading
63
+ * `/api/2.0` is auto-stripped so callers can pass either style
64
+ * (`"/api/2.0/serving-endpoints"` or `"/serving-endpoints"`) without
65
+ * doubling it in the final URL.
66
+ *
67
+ * `init` is an optional WHATWG `RequestInit`. Useful fields:
68
+ *
69
+ * - `body`: request payload. Strings / `Buffer` / `FormData` /
70
+ * `URLSearchParams` pass through; for JSON, stringify the object
71
+ * yourself and set `headers["Content-Type"] = "application/json"`.
72
+ * - `headers`: extra request headers, merged in **before** the auth
73
+ * header is applied so the workspace's `Authorization` always
74
+ * wins on conflict.
75
+ * - `method`: HTTP verb. If omitted, defaults to `POST` when
76
+ * `init.body` is present and `GET` otherwise.
77
+ *
78
+ * `cache` is an optional handle to `CacheManager.getOrExecute`: pass
79
+ * `{ options: { ttl: 300 } }` for a per-user, time-boxed cache; the
80
+ * `userId` from the active execution context becomes part of the
81
+ * cache key by default.
82
+ *
83
+ * `workspaceClient` is optional; when omitted the request uses the
84
+ * caller's `getExecutionContext().client` (i.e. the per-request
85
+ * OBO client). Pass an explicit client for service-account work
86
+ * outside a request.
87
+ *
88
+ * @example
89
+ * await fetchApi("/serving-endpoints");
90
+ *
91
+ * await fetchApi(["/serving-endpoints", endpointName, "invocations"], {
92
+ * body: JSON.stringify({ inputs: [...] }),
93
+ * headers: { "Content-Type": "application/json" },
94
+ * });
95
+ *
96
+ * await fetchApi("/serving-endpoints", undefined,
97
+ * { options: { ttl: 300 } }
98
+ * );
99
+ */
100
+ export async function fetchApi<T>(
101
+ target: URL | string[] | string,
102
+ init?: ApiRequestInit,
103
+ ): Promise<T> {
104
+ const client = init?.workspaceClient ?? getExecutionContext().client;
105
+ const config = client.config;
106
+ const url = target instanceof URL ? target : await apiUrl(target, client);
107
+ if (init?.cache) {
108
+ const { cache, ...executeInit } = init;
109
+ const executionContext = getExecutionContext();
110
+ const userId =
111
+ "userId" in executionContext
112
+ ? executionContext.userId
113
+ : executionContext.serviceUserId;
114
+ const cacheInstance = await CacheManager.getInstance();
115
+
116
+ return cacheInstance.getOrExecute(
117
+ cache.key ?? ["fetchApi", userId],
118
+ async () => {
119
+ return fetchApi(url, { ...executeInit, workspaceClient: client });
120
+ },
121
+ cache.userKey ?? fnvHash(url.toString()),
122
+ cache.options,
123
+ );
124
+ }
125
+ const headers = new Headers(init?.headers);
126
+ await config.authenticate(headers);
127
+ const method = init?.method?.toUpperCase() ?? (init?.body ? "POST" : "GET");
128
+ const response = await fetch(url.toString(), {
129
+ ...init,
130
+ method,
131
+ headers,
132
+ });
133
+ return response.json() as Promise<T>;
134
+ }
135
+
136
+ export type ContextLike = Context | AbortSignal;
137
+
138
+ /** Wrap a `Context` (returned as-is) or `AbortSignal` (adapted) as an SDK `Context`. */
139
+ export function toContext(input: ContextLike): Context;
140
+ /**
141
+ * Derive an SDK `Context` from `controller.signal`, optionally tying
142
+ * `input` into the controller so the controller becomes the single
143
+ * cancellation source for downstream SDK calls:
144
+ *
145
+ * - `AbortSignal`: aborting it propagates into `controller` (and from
146
+ * there into every SDK call you pass the returned context to).
147
+ * - `Context`: its `cancellationToken` is tied into `controller`, and
148
+ * its other fields (`logger`, `opName`, `rootClassName`,
149
+ * `rootFnName`, `opId`) are preserved in the returned `Context`.
150
+ * The returned context's `cancellationToken` is replaced with one
151
+ * backed by `controller.signal`.
152
+ *
153
+ * The tie is one-way (parent -> child): aborting `controller`
154
+ * directly does NOT cancel `input`. So a request-level cancel (your
155
+ * loop's `try/finally { controller.abort() }`) won't tear down a
156
+ * caller-supplied AbortSignal it didn't own.
157
+ */
158
+ export function toContext(controller: AbortController, input?: ContextLike): Context;
159
+ export function toContext(
160
+ source: AbortController | ContextLike,
161
+ input?: ContextLike,
162
+ ): Context {
163
+ if (!(source instanceof AbortController)) {
164
+ if (source instanceof Context) return source;
165
+ return new Context({ cancellationToken: signalToCancellationToken(source) });
166
+ }
167
+ if (input instanceof AbortSignal) {
168
+ tieAbortSignal(source, input);
169
+ } else if (input instanceof Context) {
170
+ const token = input.cancellationToken;
171
+ if (token) tieCancellationToken(source, token);
172
+ const merged = input.copy();
173
+ merged.setItems({ cancellationToken: signalToCancellationToken(source.signal) });
174
+ return merged;
175
+ }
176
+ return new Context({ cancellationToken: signalToCancellationToken(source.signal) });
177
+ }
178
+
179
+ /**
180
+ * Adapt a WHATWG `AbortSignal` to the Databricks SDK's
181
+ * `CancellationToken` interface. The SDK's `api-client.ts`
182
+ * internally creates an `AbortController` and wires
183
+ * `cancellationToken.onCancellationRequested` to it, so this
184
+ * adapter is the one-line bridge from "platform-standard
185
+ * cancellation" to "the SDK aborts the fetch on your behalf".
186
+ *
187
+ * Kept private for now: the genie package is the only consumer in
188
+ * the workspace. Lift to `@dbx-tools/shared` (`apiUtils`) the
189
+ * moment a second package needs SDK-call cancellation.
190
+ */
191
+ function signalToCancellationToken(signal: AbortSignal): CancellationToken {
192
+ return {
193
+ get isCancellationRequested() {
194
+ return signal.aborted;
195
+ },
196
+ onCancellationRequested(cb) {
197
+ if (signal.aborted) {
198
+ cb(signal.reason);
199
+ return;
200
+ }
201
+ signal.addEventListener("abort", () => cb(signal.reason), { once: true });
202
+ },
203
+ };
204
+ }
205
+
206
+ /**
207
+ * Tie the SDK's `CancellationToken` interface back into an
208
+ * `AbortController`. Mirrors {@link tieAbortSignal} but for the
209
+ * SDK's cancellation shape, used when a caller hands us a
210
+ * pre-built `Context` whose token we want to fold into our own
211
+ * controller.
212
+ */
213
+ function tieCancellationToken(
214
+ controller: AbortController,
215
+ token: CancellationToken,
216
+ ): void {
217
+ if (token.isCancellationRequested) {
218
+ controller.abort();
219
+ return;
220
+ }
221
+ token.onCancellationRequested((reason) => controller.abort(reason));
222
+ }
package/src/appkit.ts ADDED
@@ -0,0 +1,161 @@
1
+ // Helpers for working with the AppKit plugin context (`this.context` on
2
+ // any class that extends `Plugin` from `@databricks/appkit`).
3
+ //
4
+ // Why these live here instead of in `@databricks/appkit`: AppKit exposes
5
+ // `this.context.getPlugins()`, which returns
6
+ // `ReadonlyMap<string, BasePlugin>`, but provides no typed lookup
7
+ // helper. Every caller ends up writing the same
8
+ // `as InstanceType<ReturnType<typeof someFactory>["plugin"]>` cast.
9
+ // These wrappers absorb that boilerplate.
10
+ //
11
+ // API shape: pass the plugin's factory (`lakebase`, `serving`, `genie`,
12
+ // or any `toPlugin(...)` result) directly. TypeScript infers both the
13
+ // instance type (so `.exports()` resolves) and the registered name (so
14
+ // the runtime lookup works) from that single value. No `<T>` annotation
15
+ // or string literal needed at the call site.
16
+
17
+ import {
18
+ CacheManager,
19
+ createApp,
20
+ getExecutionContext,
21
+ InitializationError,
22
+ } from "@databricks/appkit";
23
+ import type { NameLike } from "./common.js";
24
+ import { memoize } from "./common.js";
25
+
26
+ // Minimal structural shape of `this.context`. We mirror only the method
27
+ // we touch instead of depending on AppKit's `PluginContext` type, which
28
+ // is not part of the package's `exports` map and therefore cannot be
29
+ // imported. Any compatible object (real `PluginContext`, mocks, tests)
30
+ // satisfies this shape.
31
+ export interface PluginContextLike {
32
+ getPlugins(): ReadonlyMap<string, unknown>;
33
+ }
34
+
35
+ type PluginData = {
36
+ plugin: abstract new (...args: never[]) => unknown;
37
+ name: string;
38
+ };
39
+
40
+ // Structural shape of an AppKit plugin factory (the result of
41
+ // `toPlugin(SomePluginClass)`). Calling it returns a `PluginData` tuple
42
+ // whose `plugin` field is the *class constructor* and whose `name`
43
+ // field carries the registered plugin name as a literal string.
44
+ //
45
+ // Defined structurally so we don't pull `@databricks/appkit` into this
46
+ // package as a runtime or type dependency. Any function returning the
47
+ // same shape (e.g. `lakebase`, `serving`, `genie`, or a user-defined
48
+ // `toPlugin(MyPlugin)`) satisfies the bound.
49
+ type PluginDataFactory = (...args: never[]) => PluginData;
50
+
51
+ // Maps a plugin factory back to the *instance* type of its plugin
52
+ // class. Mirrors the inline pattern users would otherwise write:
53
+ // `InstanceType<ReturnType<typeof factory>["plugin"]>`.
54
+ type PluginInstanceOf<F extends PluginDataFactory> = InstanceType<
55
+ ReturnType<F>["plugin"]
56
+ >;
57
+
58
+ // Registry name returned by `factory().name`, keyed by the factory
59
+ // function. Typical AppKit factories return stable metadata; caching
60
+ // avoids invoking `factory()` on every sibling lookup (which would
61
+ // allocate a fresh descriptor tuple each time).
62
+ const dataCache = new WeakMap<PluginDataFactory, PluginData>();
63
+
64
+ /**
65
+ * Returns the static `{ plugin, name }` descriptor for an AppKit plugin
66
+ * factory, caching per factory so repeated lookups do not allocate.
67
+ */
68
+ export function data<F extends PluginDataFactory, D extends ReturnType<F>>(
69
+ factory: F,
70
+ ): D {
71
+ const cached = dataCache.get(factory);
72
+ if (cached !== undefined) {
73
+ return cached as D;
74
+ }
75
+ const result = factory();
76
+ dataCache.set(factory, result);
77
+ return result as D;
78
+ }
79
+
80
+ /**
81
+ * Look up a sibling plugin instance from the AppKit plugin context,
82
+ * keyed off the factory's registered name and typed via its plugin
83
+ * class.
84
+ *
85
+ * Returns `undefined` when the context is missing or the plugin is not
86
+ * registered. For required siblings prefer {@link require}.
87
+ *
88
+ * @example
89
+ * ```ts
90
+ * import { lakebase } from "@databricks/appkit";
91
+ * import { appkitUtils } from "@dbx-tools/shared";
92
+ *
93
+ * const lake = appkitUtils.instance(this.context, lakebase);
94
+ * // ^^ inferred as LakebasePlugin | undefined
95
+ * lake?.exports().pool;
96
+ * ```
97
+ */
98
+ export function instance<F extends PluginDataFactory>(
99
+ ctx: PluginContextLike | undefined,
100
+ factory: F,
101
+ ): PluginInstanceOf<F> | undefined {
102
+ if (!ctx) return undefined;
103
+ const name = data(factory).name;
104
+ return ctx.getPlugins().get(name) as PluginInstanceOf<F> | undefined;
105
+ }
106
+
107
+ /**
108
+ * Like {@link instance} but throws when the plugin is not registered.
109
+ * Use for siblings whose absence is a wiring bug rather than a runtime
110
+ * condition (e.g. requiring `lakebase` when the caller has `storage` /
111
+ * `memory` enabled).
112
+ *
113
+ * `caller` is prepended to the error message so cross-plugin failures
114
+ * are easy to attribute in logs.
115
+ *
116
+ * Always accessed through the namespace as `appkitUtils.require(...)`;
117
+ * the bare identifier is legal here because this package is pure ESM.
118
+ *
119
+ * @example
120
+ * ```ts
121
+ * import { lakebase } from "@databricks/appkit";
122
+ * import { appkitUtils } from "@dbx-tools/shared";
123
+ *
124
+ * const pool = appkitUtils.require(this.context, lakebase, "mastra")
125
+ * .exports().pool;
126
+ * ```
127
+ */
128
+ export function require<F extends PluginDataFactory>(
129
+ ctx: PluginContextLike | undefined,
130
+ factory: F,
131
+ caller?: NameLike | string,
132
+ ): PluginInstanceOf<F> {
133
+ const plugin = instance(ctx, factory);
134
+ if (plugin) return plugin;
135
+ const prefix =
136
+ typeof caller === "string" ? `${caller}: ` : caller?.name ? `${caller.name}: ` : "";
137
+ const registeredName = data(factory).name;
138
+ throw new Error(`${prefix}required plugin not registered: ${registeredName}`);
139
+ }
140
+
141
+ export function isInitialized(): boolean {
142
+ try {
143
+ const ctx = getExecutionContext();
144
+ if (ctx?.client) {
145
+ return true;
146
+ }
147
+ } catch (error) {
148
+ if (!(error instanceof InitializationError)) {
149
+ throw error;
150
+ }
151
+ }
152
+ return false;
153
+ }
154
+
155
+ export async function ensureInitialized() {
156
+ if (!isInitialized()) {
157
+ await createApp({
158
+ plugins: [],
159
+ });
160
+ }
161
+ }