@dbx-tools/appkit 0.1.9

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/src/config.ts ADDED
@@ -0,0 +1,445 @@
1
+ /**
2
+ * Layered configuration resolution for local development against a Databricks
3
+ * App / Asset Bundle.
4
+ *
5
+ * Default sources: `env`, then Databricks App `config.env` (from {@link bundle})
6
+ * and hard-coded `app.yaml` env entries (from {@link appYaml}). Opt in to `cli`
7
+ * when a dev command wants flag overrides.
8
+ *
9
+ * Server-only (`node:child_process`, bundle root discovery). Databricks-app
10
+ * specific, so it lives in node-appkit rather than shared-core.
11
+ */
12
+
13
+ import { readFile } from "node:fs/promises";
14
+ import { spawnSync } from "node:child_process";
15
+ import { dirname, resolve } from "node:path";
16
+ import { functionModule, iterable, log, string } from "@dbx-tools/shared-core";
17
+ import { project } from "@dbx-tools/core";
18
+ import { parse as parseYamlText } from "yaml";
19
+ import { z } from "zod";
20
+
21
+ import { isAppEnv } from "./databricks";
22
+
23
+ const logger = log.logger("config");
24
+
25
+ /** Parsed payload from `databricks bundle validate --output json`. */
26
+ export type BundleValidateJson = Record<string, unknown>;
27
+
28
+ /** A config file discovered on disk with its parsed contents. */
29
+ export interface ConfigFile {
30
+ path: string;
31
+ data: Record<string, unknown>;
32
+ }
33
+
34
+ /** Supported configuration sources, consulted in array order. */
35
+ export type ConfigSource = "explicit" | "cli" | "env" | "bundle";
36
+
37
+ const defaultConfigSources: ConfigSource[] = ["env", "bundle"];
38
+
39
+ const APP_YAML_NAMES = ["app.yaml", "app.yml"] as const;
40
+
41
+ const bundleValidateCache = new Map<string, BundleValidateJson | undefined>();
42
+
43
+ const normalizedString = z
44
+ .string()
45
+ .transform((value) => value.trim())
46
+ .pipe(z.string().min(1));
47
+
48
+ const bundleAppEnvEntrySchema = z.object({
49
+ name: normalizedString.optional(),
50
+ value: z.string().optional(),
51
+ value_from: normalizedString.optional(),
52
+ });
53
+
54
+ const bundleAppResourceSchema = z.object({
55
+ name: normalizedString.optional(),
56
+ sql_warehouse: z.object({ id: normalizedString.optional() }).optional(),
57
+ genie_space: z.object({ space_id: normalizedString.optional() }).optional(),
58
+ postgres: z
59
+ .object({
60
+ database: normalizedString.optional(),
61
+ branch: normalizedString.optional(),
62
+ endpoint: normalizedString.optional(),
63
+ })
64
+ .optional(),
65
+ });
66
+
67
+ const bundleAppSchema = z.object({
68
+ name: normalizedString.optional(),
69
+ source_code_path: z.string().optional(),
70
+ config: z.object({ env: z.array(bundleAppEnvEntrySchema).optional() }).optional(),
71
+ resources: z.array(bundleAppResourceSchema).optional(),
72
+ });
73
+
74
+ const bundleValidateAppsSchema = z.object({
75
+ resources: z.object({ apps: z.record(z.string(), bundleAppSchema).optional() }).optional(),
76
+ });
77
+
78
+ const appYamlEnvEntrySchema = z.object({
79
+ name: normalizedString,
80
+ value: z.string().optional(),
81
+ valueFrom: normalizedString.optional(),
82
+ });
83
+
84
+ const appYamlResourceSchema = z
85
+ .object({
86
+ name: normalizedString,
87
+ sql_warehouse: z.object({ id: normalizedString.optional() }).optional(),
88
+ genie_space: z.object({ space_id: normalizedString.optional() }).optional(),
89
+ postgres: z
90
+ .object({
91
+ database: normalizedString.optional(),
92
+ branch: normalizedString.optional(),
93
+ endpoint: normalizedString.optional(),
94
+ })
95
+ .optional(),
96
+ })
97
+ .passthrough();
98
+
99
+ const appYamlSchema = z.object({
100
+ env: z.array(appYamlEnvEntrySchema).optional(),
101
+ resources: z.array(appYamlResourceSchema).optional(),
102
+ });
103
+
104
+ type BundleApp = z.infer<typeof bundleAppSchema>;
105
+
106
+ /** Single config map entry (string or repeated values, like headers). */
107
+ export type ConfigMapValue = string | string[] | undefined;
108
+
109
+ export interface ResolveConfigValueOptions {
110
+ /** Bundle validate JSON. Defaults to {@link bundle} (skipped inside a Databricks App). */
111
+ bundleData?: ConfigFile;
112
+ /** Parsed `app.yaml` contents. Defaults to {@link appYaml} (skipped inside a Databricks App). */
113
+ appData?: ConfigFile;
114
+ /** Sources to consult, first truthy string wins. Defaults to `env`, then `bundle`. */
115
+ sources?: ConfigSource[];
116
+ /** Programmatic overrides. When set, `explicit` is appended to `sources` unless already listed. */
117
+ explicit?: Record<string, ConfigMapValue>;
118
+ /** CLI flag values (when `cli` is listed in `sources`). */
119
+ cli?: Record<string, ConfigMapValue>;
120
+ }
121
+
122
+ const bundleDefault = functionModule.memoize(() => loadBundle(process.cwd()));
123
+ const appYamlDefault = functionModule.memoize(() => loadAppYaml(process.cwd()));
124
+
125
+ function envKeysForName(name: string): iterable.Sequence<string> {
126
+ const trimmed = name.trim();
127
+ if (!trimmed) {
128
+ return iterable.sequence();
129
+ }
130
+ const keys = (function* () {
131
+ const modifiers: (((value: string) => string) | null)[] = [
132
+ null,
133
+ () => trimmed.toUpperCase(),
134
+ () => Array.from(string.tokenize(trimmed)).join("_").toUpperCase(),
135
+ ];
136
+ for (const modifier of modifiers) {
137
+ yield modifier ? modifier(trimmed) : trimmed;
138
+ }
139
+ })();
140
+ return iterable.sequence(keys).cache().filter(Boolean).distinct();
141
+ }
142
+
143
+ function readEnv(keys: Iterable<string>): string | undefined {
144
+ for (const key of keys) {
145
+ const value = process.env[key]?.trim();
146
+ if (value) return value;
147
+ }
148
+ return undefined;
149
+ }
150
+
151
+ function readConfigMapValue(value: ConfigMapValue): string | undefined {
152
+ if (value == null) return undefined;
153
+ if (Array.isArray(value)) {
154
+ for (const item of value) {
155
+ const trimmed = item?.trim();
156
+ if (trimmed) return trimmed;
157
+ }
158
+ return undefined;
159
+ }
160
+ const trimmed = value.trim();
161
+ return trimmed || undefined;
162
+ }
163
+
164
+ function readMap(
165
+ keys: Iterable<string>,
166
+ map: Record<string, ConfigMapValue> | undefined,
167
+ ): string | undefined {
168
+ if (!map) return undefined;
169
+ for (const key of keys) {
170
+ const value = readConfigMapValue(map[key]);
171
+ if (value) return value;
172
+ }
173
+ return undefined;
174
+ }
175
+
176
+ function readAppEnv(keys: Iterable<string>, envMap: Record<string, string>): string | undefined {
177
+ for (const key of keys) {
178
+ const value = envMap[key]?.trim();
179
+ if (value) return value;
180
+ }
181
+ return undefined;
182
+ }
183
+
184
+ function parseYaml(text: string): unknown {
185
+ return parseYamlText(text) as unknown;
186
+ }
187
+
188
+ function pickAppResourceId(apps: Record<string, BundleApp>): string | undefined {
189
+ const keys = Object.keys(apps);
190
+ return keys.length === 1 ? keys[0] : undefined;
191
+ }
192
+
193
+ /**
194
+ * Flatten `env` entries from parsed `app.yaml` content. Literal `value` entries
195
+ * are returned as-is; `valueFrom` entries resolve against the sibling
196
+ * `resources` array when possible.
197
+ */
198
+ export function flattenAppYamlEnv(data: unknown): Record<string, string> {
199
+ const parsed = appYamlSchema.safeParse(data);
200
+ if (!parsed.success || !parsed.data.env?.length) {
201
+ return {};
202
+ }
203
+
204
+ const resourceByName = new Map(
205
+ (parsed.data.resources ?? []).map((resource) => [resource.name, resource]),
206
+ );
207
+
208
+ const out: Record<string, string> = {};
209
+ for (const entry of parsed.data.env) {
210
+ if (entry.value?.trim()) {
211
+ out[entry.name] = entry.value.trim();
212
+ continue;
213
+ }
214
+ if (!entry.valueFrom) continue;
215
+ const resource = resourceByName.get(entry.valueFrom);
216
+ const resolved =
217
+ resource?.sql_warehouse?.id ??
218
+ resource?.genie_space?.space_id ??
219
+ resource?.postgres?.endpoint ??
220
+ resource?.postgres?.database ??
221
+ resource?.postgres?.branch;
222
+ if (resolved) out[entry.name] = resolved;
223
+ }
224
+ return out;
225
+ }
226
+
227
+ /**
228
+ * Flatten `resources.apps.<key>.config.env` into a `name -> value` map.
229
+ * Auto-picks the app only when the bundle defines exactly one.
230
+ */
231
+ export function flattenAppEnv(data: unknown): Record<string, string> {
232
+ const parsed = bundleValidateAppsSchema.safeParse(data);
233
+ if (!parsed.success) return {};
234
+
235
+ const apps = parsed.data.resources?.apps;
236
+ if (!apps || Object.keys(apps).length === 0) return {};
237
+
238
+ const key = pickAppResourceId(apps);
239
+ if (!key) return {};
240
+
241
+ const app = apps[key];
242
+ if (!app?.config?.env?.length) return {};
243
+
244
+ const resourceByName = new Map(
245
+ (app.resources ?? [])
246
+ .filter((resource) => resource.name)
247
+ .map((resource) => [resource.name!, resource]),
248
+ );
249
+
250
+ const out: Record<string, string> = {};
251
+ for (const entry of app.config.env) {
252
+ if (!entry.name) continue;
253
+ if (entry.value) {
254
+ out[entry.name] = entry.value;
255
+ continue;
256
+ }
257
+ if (!entry.value_from) continue;
258
+ const resource = resourceByName.get(entry.value_from);
259
+ const resolved =
260
+ resource?.sql_warehouse?.id ??
261
+ resource?.genie_space?.space_id ??
262
+ resource?.postgres?.endpoint ??
263
+ resource?.postgres?.database ??
264
+ resource?.postgres?.branch;
265
+ if (resolved) out[entry.name] = resolved;
266
+ }
267
+ return out;
268
+ }
269
+
270
+ function validateBundle(root: string): BundleValidateJson | undefined {
271
+ const key = root;
272
+ if (bundleValidateCache.has(key)) {
273
+ return bundleValidateCache.get(key);
274
+ }
275
+
276
+ const args = ["bundle", "validate", "--output", "json"];
277
+ try {
278
+ const proc = spawnSync("databricks", args, {
279
+ cwd: root,
280
+ encoding: "utf8",
281
+ stdio: ["ignore", "pipe", "pipe"],
282
+ });
283
+ const text = proc.stdout?.trim();
284
+ if (!text) {
285
+ bundleValidateCache.set(key, undefined);
286
+ return undefined;
287
+ }
288
+ const data = JSON.parse(text) as BundleValidateJson;
289
+ bundleValidateCache.set(key, data);
290
+ return data;
291
+ } catch {
292
+ bundleValidateCache.set(key, undefined);
293
+ return undefined;
294
+ }
295
+ }
296
+
297
+ async function loadBundle(cwd: string): Promise<ConfigFile | undefined> {
298
+ const configFile = resolveConfigFile(cwd, "databricks.yml");
299
+ if (configFile) {
300
+ const data = validateBundle(dirname(configFile));
301
+ if (data) return { path: configFile, data };
302
+ }
303
+ return undefined;
304
+ }
305
+
306
+ async function loadAppYaml(cwd: string): Promise<ConfigFile | undefined> {
307
+ for (const fileName of APP_YAML_NAMES) {
308
+ const configFile = resolveConfigFile(cwd, fileName);
309
+ if (!configFile) continue;
310
+ try {
311
+ const text = await readFile(configFile, "utf8");
312
+ const data = parseYaml(text);
313
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
314
+ return undefined;
315
+ }
316
+ return { path: configFile, data: data as Record<string, unknown> };
317
+ } catch {
318
+ logger.warn("failed to parse app yaml", { path: configFile });
319
+ }
320
+ }
321
+ return undefined;
322
+ }
323
+
324
+ function resolveConfigFile(cwd: string, configFile: string): string | undefined {
325
+ if (isAppEnv()) return undefined;
326
+ for (const rootDir of project.resolveProjectRoots(cwd)) {
327
+ const bundlePath = resolve(rootDir, configFile);
328
+ if (project.stat(bundlePath)?.isFile()) return bundlePath;
329
+ }
330
+ return undefined;
331
+ }
332
+
333
+ /**
334
+ * Locate the bundle root and run `databricks bundle validate --output json`.
335
+ * When `cwd` is omitted or equals `process.cwd()`, the result is memoized for
336
+ * the process lifetime. Returns `undefined` inside a Databricks App.
337
+ */
338
+ export function bundle(cwd?: string): Promise<ConfigFile | undefined> {
339
+ if (isAppEnv()) return Promise.resolve(undefined);
340
+ return cwd && resolve(cwd) !== process.cwd() ? loadBundle(cwd) : bundleDefault();
341
+ }
342
+
343
+ /**
344
+ * Locate and parse `app.yaml` / `app.yml` from the bundle or project root. When
345
+ * `cwd` is omitted or equals `process.cwd()`, the result is memoized for the
346
+ * process lifetime. Returns `undefined` inside a Databricks App.
347
+ */
348
+ export function appYaml(cwd?: string): Promise<ConfigFile | undefined> {
349
+ if (isAppEnv()) return Promise.resolve(undefined);
350
+ return cwd && resolve(cwd) !== process.cwd() ? loadAppYaml(cwd) : appYamlDefault();
351
+ }
352
+
353
+ /**
354
+ * Walk a dot-separated path through bundle validate JSON. When the terminal
355
+ * node is a bundle variable object (`{ value: "..." }`), the `value` field is
356
+ * returned.
357
+ */
358
+ export function getBundlePath(data: BundleValidateJson, path: string): string | undefined {
359
+ const parts = path.split(".").filter(Boolean);
360
+ if (parts.length === 0) return undefined;
361
+
362
+ let current: unknown = data;
363
+ for (let i = 0; i < parts.length; i++) {
364
+ if (typeof current !== "object" || current === null) return undefined;
365
+ const record = current as Record<string, unknown>;
366
+ const part = parts[i]!;
367
+ const next = record[part];
368
+ if (i === parts.length - 1) {
369
+ if (typeof next === "string" && next) return next;
370
+ if (typeof next === "object" && next !== null && "value" in next) {
371
+ const value = (next as { value?: unknown }).value;
372
+ return typeof value === "string" && value ? value : undefined;
373
+ }
374
+ return undefined;
375
+ }
376
+ current = next;
377
+ }
378
+ return undefined;
379
+ }
380
+
381
+ async function resolveAppEnvMap(
382
+ options: ResolveConfigValueOptions,
383
+ ): Promise<Record<string, string>> {
384
+ const appData = options.appData ?? (await appYaml());
385
+ const bundleData = options.bundleData ?? (await bundle());
386
+ const fromYaml = appData ? flattenAppYamlEnv(appData.data) : {};
387
+ const fromBundle = bundleData ? flattenAppEnv(bundleData.data) : {};
388
+ return { ...fromYaml, ...fromBundle };
389
+ }
390
+
391
+ function resolveSources(options: ResolveConfigValueOptions): ConfigSource[] {
392
+ const sources = [...(options.sources ?? defaultConfigSources)];
393
+ if (options.explicit !== undefined && !sources.includes("explicit")) {
394
+ sources.push("explicit");
395
+ }
396
+ return sources;
397
+ }
398
+
399
+ /**
400
+ * Resolve a configuration string from the configured sources. Returns the first
401
+ * non-empty value, or `undefined` when nothing matches.
402
+ */
403
+ export async function resolveConfigValue(
404
+ name: string,
405
+ options: ResolveConfigValueOptions = {},
406
+ ): Promise<string | undefined> {
407
+ const keys = envKeysForName(name).toArray();
408
+ if (keys.length === 0) return undefined;
409
+ const sources = resolveSources(options);
410
+ let appEnvMap: Record<string, string> | undefined;
411
+ const values = (async function* () {
412
+ for (const source of sources) {
413
+ switch (source) {
414
+ case "explicit":
415
+ yield readMap(keys, options.explicit);
416
+ break;
417
+ case "cli":
418
+ yield readMap(keys, options.cli);
419
+ break;
420
+ case "env":
421
+ yield readEnv(keys);
422
+ break;
423
+ case "bundle":
424
+ if (appEnvMap === undefined) appEnvMap = await resolveAppEnvMap(options);
425
+ yield readAppEnv(keys, appEnvMap);
426
+ break;
427
+ default:
428
+ throw new Error(`Unknown config source: ${source}`);
429
+ }
430
+ }
431
+ })();
432
+ for await (const value of values) {
433
+ if (value) return value;
434
+ }
435
+ return undefined;
436
+ }
437
+
438
+ /**
439
+ * Sources with `cli` included, in CLI-first order. Use for dev commands that
440
+ * accept flag overrides.
441
+ */
442
+ export function withCliSources(sources: ConfigSource[] = defaultConfigSources): ConfigSource[] {
443
+ const rest = sources.filter((source) => source !== "cli" && source !== "explicit");
444
+ return ["cli", "explicit", ...rest];
445
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * `createApp` wrapper: runs dbx-tools auto-configuration, then delegates to
3
+ * AppKit's own `createApp` with the exact same arguments.
4
+ *
5
+ * Drop it in as a one-for-one replacement for `@databricks/appkit`'s
6
+ * `createApp` - same parameters, same return type, full plugin-export inference
7
+ * preserved:
8
+ *
9
+ * ```ts
10
+ * import { createApp } from "@dbx-tools/appkit";
11
+ * import { lakebase, server } from "@databricks/appkit";
12
+ *
13
+ * await createApp({ plugins: [server(), lakebase()] });
14
+ * ```
15
+ *
16
+ * Auto-configuration runs BEFORE delegating so plugins see a fully populated
17
+ * `process.env` during their synchronous `setup()`. Lakebase Postgres runs when
18
+ * a `lakebase` plugin is present, or when `autoConfigure: true` is set on the
19
+ * config object.
20
+ */
21
+
22
+ import { log } from "@dbx-tools/shared-core";
23
+ import { createApp as appkitCreateApp, getUsernameWithApiLookup } from "@databricks/appkit";
24
+
25
+ import {
26
+ applyLakebaseToEnv,
27
+ resolveLakebaseConnection,
28
+ type LakebaseConnection,
29
+ } from "./lakebase-resolver";
30
+ import { provisionCacheSchema } from "./provision";
31
+
32
+ type CreateAppConfig = Parameters<typeof appkitCreateApp>[0] & {
33
+ autoConfigure?: boolean | "provision";
34
+ };
35
+
36
+ const logger = log.logger("create-app");
37
+
38
+ const LAKEBASE_PLUGIN = "lakebase";
39
+
40
+ function usesPlugin(config: CreateAppConfig | undefined, name: string): boolean {
41
+ return Boolean(config?.plugins?.some((entry) => entry.name === name));
42
+ }
43
+
44
+ /**
45
+ * Run enabled auto-configuration steps without calling AppKit's `createApp`.
46
+ * Lakebase Postgres resolves when `autoConfigure` is `true` or a `lakebase`
47
+ * plugin is listed in `config.plugins`. Defaults to `"provision"` (Lakebase env
48
+ * + optional cache schema). Pass `autoConfigure: false` to skip entirely.
49
+ */
50
+ export async function autoConfigure(config?: CreateAppConfig): Promise<void> {
51
+ const { autoConfigure = "provision" } = config ?? {};
52
+ if (autoConfigure !== false) {
53
+ if (autoConfigure === true || usesPlugin(config, LAKEBASE_PLUGIN)) {
54
+ await autoConfigureLakebase(autoConfigure === "provision");
55
+ }
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Resolve Lakebase Postgres connection info, write the resolved values to
61
+ * `process.env`, and return the record. Used by {@link autoConfigure}; call
62
+ * {@link resolveLakebaseConnection} and {@link applyLakebaseToEnv} directly when
63
+ * finer control is needed.
64
+ */
65
+ async function autoConfigureLakebase(provision: boolean): Promise<LakebaseConnection> {
66
+ const resolved = await resolveLakebaseConnection();
67
+ applyLakebaseToEnv(resolved);
68
+ const user = await getUsernameWithApiLookup({});
69
+ if (user) process.env.PGUSER ??= user;
70
+ logger.info("env updated", { ...redactLakebaseConnection(resolved), user });
71
+ if (provision) {
72
+ await provisionCacheSchema(logger, user);
73
+ }
74
+ return resolved;
75
+ }
76
+
77
+ const create = async (config?: CreateAppConfig) => {
78
+ await autoConfigure(config);
79
+ return appkitCreateApp(config);
80
+ };
81
+
82
+ function redactLakebaseConnection(resolved: LakebaseConnection): Record<string, unknown> {
83
+ return {
84
+ project: resolved.project,
85
+ branch: resolved.branch,
86
+ endpoint: resolved.endpoint,
87
+ database: resolved.database,
88
+ host: resolved.host,
89
+ port: resolved.port,
90
+ sslMode: resolved.sslMode,
91
+ };
92
+ }
93
+
94
+ /** Auto-configuring drop-in for AppKit's `createApp`. */
95
+ export const createApp = create as unknown as typeof appkitCreateApp;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Generic Databricks SDK glue (no AppKit): adapt WHATWG cancellation
3
+ * (`AbortSignal` / `AbortController`) into the SDK's `Context` /
4
+ * `CancellationToken` shapes so a single `AbortController` can drive every
5
+ * in-flight SDK call, plus Databricks runtime-environment detection.
6
+ *
7
+ * Server-only: leans on the Databricks SDK `Context`. Lives in node-appkit so
8
+ * the browser-safe shared-core stays SDK-free.
9
+ */
10
+
11
+ import { async } from "@dbx-tools/shared-core";
12
+ import type { CancellationToken } from "@databricks/sdk-experimental";
13
+ import { Context } from "@databricks/sdk-experimental";
14
+
15
+ /**
16
+ * Detect the Databricks App runtime from environment shape: requires a
17
+ * non-empty `DATABRICKS_APP_NAME`, a `DATABRICKS_HOST` that parses as an
18
+ * `http`/`https` URL, and a `DATABRICKS_APP_PORT` that is a valid TCP port.
19
+ * Reads `process.env` when no `env` is passed.
20
+ */
21
+ export function isAppEnv(env: Record<string, string | undefined> = process.env): boolean {
22
+ const appName = env.DATABRICKS_APP_NAME?.trim();
23
+ const host = env.DATABRICKS_HOST?.trim();
24
+ const port = env.DATABRICKS_APP_PORT?.trim();
25
+
26
+ if (!appName || !host || !port) {
27
+ return false;
28
+ }
29
+
30
+ try {
31
+ const url = new URL(host);
32
+ if (!["http:", "https:"].includes(url.protocol)) {
33
+ return false;
34
+ }
35
+ } catch {
36
+ return false;
37
+ }
38
+
39
+ const portNumber = Number(port);
40
+ if (!Number.isInteger(portNumber) || portNumber < 1 || portNumber > 65535) {
41
+ return false;
42
+ }
43
+
44
+ return true;
45
+ }
46
+
47
+ /** Either an SDK `Context` or a WHATWG `AbortSignal`. */
48
+ export type ContextLike = Context | AbortSignal;
49
+
50
+ /** Wrap a `Context` (returned as-is) or `AbortSignal` (adapted) as an SDK `Context`. */
51
+ export function toContext(input: ContextLike): Context;
52
+ /**
53
+ * Derive an SDK `Context` from `controller.signal`, optionally tying `input`
54
+ * into the controller so the controller becomes the single cancellation
55
+ * source for downstream SDK calls:
56
+ *
57
+ * - `AbortSignal`: aborting it propagates into `controller` (and from there
58
+ * into every SDK call you pass the returned context to).
59
+ * - `Context`: its `cancellationToken` is tied into `controller`, and its
60
+ * other fields (`logger`, `opName`, `rootClassName`, `rootFnName`, `opId`)
61
+ * are preserved in the returned `Context`. The returned context's
62
+ * `cancellationToken` is replaced with one backed by `controller.signal`.
63
+ *
64
+ * The tie is one-way (parent -> child): aborting `controller` directly does
65
+ * NOT cancel `input`. So a request-level cancel (your loop's `try/finally {
66
+ * controller.abort() }`) won't tear down a caller-supplied AbortSignal it
67
+ * didn't own.
68
+ */
69
+ export function toContext(controller: AbortController, input?: ContextLike): Context;
70
+ export function toContext(source: AbortController | ContextLike, input?: ContextLike): Context {
71
+ if (!(source instanceof AbortController)) {
72
+ if (source instanceof Context) return source;
73
+ return new Context({ cancellationToken: signalToCancellationToken(source) });
74
+ }
75
+ if (input instanceof AbortSignal) {
76
+ async.tieAbortSignal(source, input);
77
+ } else if (input instanceof Context) {
78
+ const token = input.cancellationToken;
79
+ if (token) tieCancellationToken(source, token);
80
+ const merged = input.copy();
81
+ merged.setItems({ cancellationToken: signalToCancellationToken(source.signal) });
82
+ return merged;
83
+ }
84
+ return new Context({ cancellationToken: signalToCancellationToken(source.signal) });
85
+ }
86
+
87
+ /**
88
+ * Adapt a WHATWG `AbortSignal` to the Databricks SDK's `CancellationToken`
89
+ * interface. The SDK's `api-client.ts` internally creates an `AbortController`
90
+ * and wires `cancellationToken.onCancellationRequested` to it, so this adapter
91
+ * is the one-line bridge from "platform-standard cancellation" to "the SDK
92
+ * aborts the fetch on your behalf".
93
+ */
94
+ function signalToCancellationToken(signal: AbortSignal): CancellationToken {
95
+ return {
96
+ get isCancellationRequested() {
97
+ return signal.aborted;
98
+ },
99
+ onCancellationRequested(cb) {
100
+ if (signal.aborted) {
101
+ cb(signal.reason);
102
+ return;
103
+ }
104
+ signal.addEventListener("abort", () => cb(signal.reason), { once: true });
105
+ },
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Tie the SDK's `CancellationToken` interface back into an `AbortController`.
111
+ * Mirrors `async.tieAbortSignal` but for the SDK's cancellation shape, used
112
+ * when a caller hands us a pre-built `Context` whose token we want to fold into
113
+ * our own controller.
114
+ */
115
+ function tieCancellationToken(controller: AbortController, token: CancellationToken): void {
116
+ if (token.isCancellationRequested) {
117
+ controller.abort();
118
+ return;
119
+ }
120
+ token.onCancellationRequested((reason) => controller.abort(reason));
121
+ }