@dbx-tools/core 0.6.88 → 0.6.90

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 CHANGED
@@ -1,47 +1,25 @@
1
1
  /**
2
- * Layered configuration lookup: environment, `.env`, Databricks bundle.
2
+ * Layered configuration lookup: environment, `.env`, bundle, and `app.yaml`.
3
3
  *
4
4
  * Every package resolves settings the same way: take the caller's value, else
5
5
  * an environment variable, else a default. Local development adds two fallback
6
- * locations: `.env` files and `resources.apps.<app>.config.env` or root
7
- * `variables` in `databricks.yml`.
6
+ * locations: `.env` files, one App's `resources.apps.<app>.config.env` in
7
+ * `databricks.yml`, and literal env values in `app.yaml` / `app.yml`.
8
8
  *
9
- * Two things make it cheap to call from a hot path:
9
+ * {@link values} is LAZY (an `object.Sequence`), so `databricks bundle validate`
10
+ * is only spawned when the environment and `.env` both missed; app YAML is only
11
+ * read after the bundle source also missed.
10
12
  *
11
- * - {@link values} is LAZY (an `object.Sequence`), so `databricks bundle
12
- * validate` is only spawned when the environment and `.env` both missed.
13
- * - the parsed `.env` and bundle are cached through {@link context.cached}, so
14
- * the spawn happens once per working directory and a `cwd` change misses
15
- * rather than returning another project's config.
16
- *
17
- * Inside a deployed Databricks App both file sources are skipped by default
13
+ * Inside a deployed Databricks App all three file sources are skipped by default
18
14
  * ({@link isDatabricksAppEnv}): the platform has already turned them into real
19
15
  * environment variables, there is no bundle to validate, and the `databricks`
20
16
  * CLI is not on the image. Boolean environment overrides can force either file
21
17
  * source on or off when a tool needs different behavior.
22
18
  *
23
- * The bundle is gated on this being an AppKit project at all. `databricks bundle
24
- * validate` is a process spawn measured in seconds, and a plain library or CLI
25
- * consumer has no bundle to find, so the gate runs in three steps before the
26
- * spawn is allowed - see {@link bundleFile}:
27
- *
28
- * 1. `@databricks/appkit` is resolved WITHOUT evaluating it. It is an optional
29
- * peer, so a consumer that never installed it is not an AppKit project and
30
- * the bundle is never loaded. The probe is cached, so this costs one
31
- * resolution for the life of the process.
32
- * 2. The bundle is read (once per context) and must describe EXACTLY ONE app
33
- * with `config.env`. Nothing here says which of several apps this process
34
- * is, so an ambiguous bundle contributes nothing rather than a guess.
35
- * 3. AppKit's execution context confirms this process really is that app. The
36
- * context does not exist until AppKit boots and `getExecutionContext()`
37
- * THROWS until then, so the probe is caught and only the affirmative is
38
- * remembered - a lookup before boot still resolves, and re-confirms later
39
- * once the context is available.
40
- *
41
- * Only the single app's `config.env` is consulted. Root bundle `variables` are
42
- * not: they are authoring inputs for the bundle itself (interpolated into
43
- * targets, resources, and paths), so treating one as a process setting resolves
44
- * names the deployed app never sees.
19
+ * Only the single bundle app's `config.env` and literal app YAML `env[].value`
20
+ * entries are consulted. Root bundle `variables` are not: they are authoring
21
+ * inputs for the bundle itself (interpolated into targets, resources, and paths),
22
+ * so treating one as a process setting resolves names the deployed app never sees.
45
23
  *
46
24
  * Node-only (`child_process`, `fs`, `process`).
47
25
  *
@@ -50,27 +28,24 @@
50
28
 
51
29
  import { spawnSync } from "node:child_process";
52
30
  import { readFileSync } from "node:fs";
53
- import { createRequire } from "node:module";
54
31
  import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
55
32
  import { parseEnv } from "node:util";
56
- import {
57
- context,
58
- functionModule,
59
- json,
60
- log,
61
- object,
62
- string as stringModule,
63
- } from "@dbx-tools/shared-core";
33
+ import { json, log, object, string as sharedString } from "@dbx-tools/shared-core";
34
+ import { parse as parseYaml } from "yaml";
64
35
  import { z } from "zod";
65
- import { statSync } from "./file.ts";
66
- import { root as resolveProjectRoot } from "./project.ts";
36
+ import { cachedRecord, statSync } from "./file.ts";
37
+ import { resolveWorkingDirectory, root as resolveProjectRoot } from "./project.ts";
67
38
 
68
39
  const logger = log.logger("config");
40
+ const configFileCache = new Map<string, string | undefined>();
69
41
 
70
- export type ConfigKey = string | readonly string[];
42
+ type ConfigKey = string | readonly string[];
43
+
44
+ export type ConfigMapValue = string | readonly string[] | null | undefined;
45
+ export type ConfigData = Readonly<Record<string, ConfigMapValue>>;
71
46
 
72
47
  /** Where a value may come from, consulted in the order given. */
73
- export type ConfigSource = "env" | "dotenv" | "bundle";
48
+ export type ConfigSource = "config" | "env" | "dotenv" | "bundle" | "app";
74
49
 
75
50
  export interface ConfigOptions {
76
51
  /**
@@ -79,10 +54,16 @@ export interface ConfigOptions {
79
54
  scope?: string | readonly string[];
80
55
  /** Capability namespaces inserted after the scope and before each key. */
81
56
  prefix?: string | readonly string[];
57
+ /** Constant config maps read by the `config` source. */
58
+ data?: ConfigData | readonly ConfigData[];
59
+ /** Parsed bundle data used instead of loading `databricks.yml`. */
60
+ bundleData?: ConfigFile | Record<string, unknown>;
61
+ /** Parsed app YAML data used instead of loading `app.yaml`. */
62
+ appData?: ConfigFile | Record<string, unknown>;
63
+ /** Sources in precedence order. Default: `config`, `env`, `dotenv`, `bundle`, `app`. */
64
+ sources?: ConfigSource | readonly ConfigSource[];
82
65
  /** Directory to resolve `.env` and the bundle from. Default: `process.cwd()`. */
83
66
  cwd?: string;
84
- /** Sources in precedence order. Default: `env`, `dotenv`, `bundle`. */
85
- sources?: ConfigSource | readonly ConfigSource[];
86
67
  }
87
68
 
88
69
  /** One resolved value, tagged with the key and source it came from. */
@@ -99,8 +80,9 @@ export interface ConfigFile {
99
80
  }
100
81
 
101
82
  const DEFAULT_SCOPE = "DBX_TOOLS";
102
- const DEFAULT_SOURCES: readonly ConfigSource[] = ["env", "dotenv", "bundle"];
83
+ const DEFAULT_SOURCES: readonly ConfigSource[] = ["config", "env", "dotenv", "bundle", "app"];
103
84
  const BUNDLE_FILE_NAMES = ["databricks.yml", "databricks.yaml"] as const;
85
+ const APP_FILE_NAMES = ["app.yaml", "app.yml"] as const;
104
86
  const DOTENV_FILE_NAME = ".env";
105
87
  const NODE_ENV_ALTERNATIVES = {
106
88
  production: ["prod"],
@@ -111,77 +93,171 @@ const NODE_ENV_ALTERNATIVES = {
111
93
  export const MAX_TCP_PORT = 65_535;
112
94
 
113
95
  /** Boolean environment override for {@link isDatabricksAppEnv}. */
114
- export const DATABRICKS_APP_ENV_KEY = "DBX_TOOLS_DATABRICKS_APP_ENV";
96
+ const DATABRICKS_APP_ENV_KEY = "DBX_TOOLS_DATABRICKS_APP_ENV";
115
97
 
116
98
  /** Boolean environment override for project `.env` reads. */
117
- export const CONFIG_DOTENV_KEY = "DBX_TOOLS_CONFIG_DOTENV";
99
+ const CONFIG_DOTENV_KEY = "DBX_TOOLS_CONFIG_DOTENV";
118
100
 
119
101
  /** Boolean environment override for Databricks bundle reads. */
120
- export const CONFIG_BUNDLE_KEY = "DBX_TOOLS_CONFIG_BUNDLE";
121
-
122
- /**
123
- * The OPTIONAL AppKit peer whose presence marks this as a Databricks App
124
- * project. Held as a constant so the resolve probe and the lazy import name it
125
- * once, and so neither site carries a literal a bundler would try to follow.
126
- */
127
- const APPKIT_MODULE = "@databricks/appkit";
128
-
129
- /**
130
- * The one thing this module needs from AppKit: whether a real execution context
131
- * is active. Typed structurally so an OPTIONAL peer never becomes a type
132
- * dependency, and `client` is `unknown` because its presence is all that is read.
133
- */
134
- type AppKitModule = {
135
- getExecutionContext(): { client?: unknown } | undefined;
136
- };
137
-
138
- /**
139
- * Whether AppKit's execution context has EVER been observed. Latching is the
140
- * point: a context confirms this process is the bundle's app, and that fact does
141
- * not stop being true when a later lookup happens outside a request scope.
142
- */
143
- let appKitContext = false;
102
+ const CONFIG_BUNDLE_KEY = "DBX_TOOLS_CONFIG_BUNDLE";
144
103
 
145
- /** Flattened App `config.env` per parsed bundle - see {@link bundleApp}. */
146
- const BUNDLE_APP_CACHE = new WeakMap<object, Record<string, string>>();
104
+ /** Boolean environment override for Databricks App YAML reads. */
105
+ const CONFIG_APP_KEY = "DBX_TOOLS_CONFIG_APP";
147
106
 
148
107
  /** Exact process-environment lookup for callers that do not read local config files. */
149
108
  export const ENV_ONLY = { scope: [] as const, sources: "env" as const };
150
109
 
151
- export const bundleValue = z
110
+ export const valueSchema = z
152
111
  .string()
153
- .transform((value: string) => value.trim())
154
- .refine((value: string) => value.length > 0)
112
+ .trim()
113
+ .min(1)
155
114
  .refine((value: string) => !/\$\{[^}]+\}/.test(value), {
156
115
  message: "Interpolated values are not allowed",
157
116
  });
158
117
 
159
- /**
160
- * The GENERIC shape of a bundle resource: a name, and whatever else the resource
161
- * type carries. Deliberately unopinionated and `passthrough()` - the concrete
162
- * resource kinds (`sql_warehouse`, `genie_space`, `postgres`, ...) are
163
- * Databricks-App concepts that belong to the package that resolves them, so
164
- * node-appkit `.extend()`s this rather than this module knowing about them.
165
- */
166
- export const bundleResourceSchema = z.object({ name: bundleValue.optional() }).passthrough();
118
+ const portValueSchema = z.preprocess(
119
+ (input) => object.toNumber(input),
120
+ z.number().int().min(1).max(MAX_TCP_PORT),
121
+ );
122
+
123
+ const positiveNumberValue = z.preprocess((input) => object.toNumber(input), z.number().positive());
124
+ const positiveIntValue = positiveNumberValue.transform(Math.floor);
125
+
126
+ const workspaceHostSchema = valueSchema.refine(
127
+ (host) => {
128
+ try {
129
+ return ["http:", "https:"].includes(new URL(host).protocol);
130
+ } catch {
131
+ return false;
132
+ }
133
+ },
134
+ { message: "Must be an HTTP(S) URL" },
135
+ );
136
+
137
+ const databricksAppEnvSchema = z.object({
138
+ DATABRICKS_APP_NAME: valueSchema,
139
+ DATABRICKS_HOST: workspaceHostSchema,
140
+ DATABRICKS_APP_PORT: portValueSchema,
141
+ });
142
+
143
+ /** A named bundle or App resource; concrete resource fields pass through. */
144
+ export const bundleResourceSchema = z.object({ name: valueSchema.optional() }).passthrough();
167
145
 
168
146
  export const bundleEnvEntrySchema = z.object({
169
- name: bundleValue.optional(),
147
+ name: valueSchema.optional(),
170
148
  value: z.string().optional(),
171
- value_from: bundleValue.optional(),
149
+ value_from: valueSchema.optional(),
172
150
  });
173
151
 
174
152
  export const bundleAppSchema = z.object({
175
- name: bundleValue.optional(),
176
- source_code_path: z.string().optional(),
153
+ name: valueSchema.optional(),
177
154
  config: z.object({ env: z.array(bundleEnvEntrySchema).optional() }).optional(),
178
155
  resources: z.array(bundleResourceSchema).optional(),
179
156
  });
180
157
 
181
- const bundleAppsSchema = z.object({
158
+ export const appEnvEntrySchema = z.object({
159
+ name: valueSchema,
160
+ value: z.string().optional(),
161
+ valueFrom: valueSchema.optional(),
162
+ });
163
+
164
+ export const appSchema = z.object({
165
+ env: z.array(appEnvEntrySchema).optional(),
166
+ resources: z.array(bundleResourceSchema).optional(),
167
+ });
168
+
169
+ const bundleConfigSchema = z.object({
182
170
  resources: z.object({ apps: z.record(z.string(), bundleAppSchema).optional() }).optional(),
183
171
  });
184
172
 
173
+ /** Flatten the single bundle App's `config.env`, resolving resource references. */
174
+ export function flattenBundleEnv(data: unknown): Record<string, string> {
175
+ const parsed = bundleConfigSchema.safeParse(data);
176
+ if (!parsed.success) return {};
177
+ const apps = Object.values(parsed.data.resources?.apps ?? {});
178
+ if (apps.length !== 1) return {};
179
+ const app = apps[0];
180
+ const resources = new Map(
181
+ (app?.resources ?? [])
182
+ .filter((resource) => resource.name)
183
+ .map((resource) => [resource.name!, resource] as const),
184
+ );
185
+ const result: Record<string, string> = {};
186
+ for (const entry of app?.config?.env ?? []) {
187
+ if (!entry.name) continue;
188
+ const literal = valueSchema.safeParse(entry.value);
189
+ if (literal.success) {
190
+ result[entry.name] = literal.data;
191
+ continue;
192
+ }
193
+ const referenced = entry.value_from
194
+ ? resourceValue(resources.get(entry.value_from))
195
+ : undefined;
196
+ if (referenced) result[entry.name] = referenced;
197
+ }
198
+ return result;
199
+ }
200
+
201
+ /** Flatten `app.yaml` env entries, resolving `valueFrom` resource references. */
202
+ export function flattenAppEnv(data: unknown): Record<string, string> {
203
+ const parsed = appSchema.safeParse(data);
204
+ if (!parsed.success) return {};
205
+ const resources = new Map(
206
+ (parsed.data.resources ?? [])
207
+ .filter((resource) => resource.name)
208
+ .map((resource) => [resource.name!, resource] as const),
209
+ );
210
+ const result: Record<string, string> = {};
211
+ for (const entry of parsed.data.env ?? []) {
212
+ const literal = valueSchema.safeParse(entry.value);
213
+ if (literal.success) {
214
+ result[entry.name] = literal.data;
215
+ continue;
216
+ }
217
+ const referenced = entry.valueFrom ? resourceValue(resources.get(entry.valueFrom)) : undefined;
218
+ if (referenced) result[entry.name] = referenced;
219
+ }
220
+ return result;
221
+ }
222
+
223
+ function resourceValue(resource: unknown): string | undefined {
224
+ for (const path of [
225
+ ["sql_warehouse", "id"],
226
+ ["genie_space", "space_id"],
227
+ ["postgres", "endpoint"],
228
+ ["postgres", "database"],
229
+ ["postgres", "branch"],
230
+ ] as const) {
231
+ let value = resource;
232
+ for (const part of path) value = object.isRecord(value) ? value[part] : undefined;
233
+ const parsed = valueSchema.safeParse(value);
234
+ if (parsed.success) return parsed.data;
235
+ }
236
+ return undefined;
237
+ }
238
+
239
+ /** Walk a dot-separated path through parsed bundle data. */
240
+ export function getBundlePath(data: Record<string, unknown>, path: string): string | undefined {
241
+ const parts = path.split(".").filter(Boolean);
242
+ if (parts.length === 0) return undefined;
243
+ let current: unknown = data;
244
+ for (let index = 0; index < parts.length; index++) {
245
+ if (!object.isRecord(current)) return undefined;
246
+ const next = current[parts[index]!];
247
+ if (index === parts.length - 1) {
248
+ const direct = valueSchema.safeParse(next);
249
+ if (direct.success) return direct.data;
250
+ if (object.isRecord(next)) {
251
+ const nested = valueSchema.safeParse(next.value);
252
+ return nested.success ? nested.data : undefined;
253
+ }
254
+ return undefined;
255
+ }
256
+ current = next;
257
+ }
258
+ return undefined;
259
+ }
260
+
185
261
  /**
186
262
  * Detect a Databricks App runtime from its required name, host, and port.
187
263
  *
@@ -195,60 +271,78 @@ export function isDatabricksAppEnv(
195
271
  ): boolean {
196
272
  const override = object.toBoolean(source[DATABRICKS_APP_ENV_KEY]);
197
273
  if (override !== undefined) return override;
198
- const appName = source.DATABRICKS_APP_NAME?.trim();
199
- const host = source.DATABRICKS_HOST?.trim();
200
- const port = source.DATABRICKS_APP_PORT?.trim();
201
- if (!appName || !host || !port) return false;
202
- try {
203
- if (!["http:", "https:"].includes(new URL(host).protocol)) return false;
204
- } catch {
205
- return false;
206
- }
207
- const portNumber = object.toNumber(port);
208
- return (
209
- portNumber !== undefined &&
210
- Number.isInteger(portNumber) &&
211
- portNumber >= 1 &&
212
- portNumber <= MAX_TCP_PORT
213
- );
274
+ const parsed = databricksAppEnvSchema.safeParse(source);
275
+ return parsed.success;
276
+ }
277
+
278
+ /** Exact, uppercase, and tokenized-uppercase names for a human-friendly key. */
279
+ export function environmentKeys(name: string): readonly string[] {
280
+ const trimmed = name.trim();
281
+ if (!trimmed) return [];
282
+ return object
283
+ .sequence(
284
+ trimmed,
285
+ trimmed.toUpperCase(),
286
+ Array.from(sharedString.tokenize(trimmed)).join("_").toUpperCase(),
287
+ )
288
+ .filter(Boolean)
289
+ .distinct()
290
+ .toArray();
214
291
  }
215
292
 
216
293
  /** Candidate names in scope, prefix, and input precedence order. */
217
- function keys(input: ConfigKey, options: Pick<ConfigOptions, "scope" | "prefix"> = {}): string[] {
218
- const scopes = object.sequence(options.scope ?? DEFAULT_SCOPE).toArray();
219
- const prefixes = options.prefix === undefined ? [] : object.sequence(options.prefix).toArray();
220
- return [
221
- ...object
222
- .sequence(input)
223
- .flatMap((key) => {
224
- const prefixed = prefixes.length > 0 ? prefixes.map((prefix) => join(prefix, key)) : [key];
225
- return [
226
- ...scopes.flatMap((scope) => prefixed.map((key) => join(scope, key))),
227
- ...prefixed,
228
- key,
229
- ];
230
- })
231
- .filter((key) => key.length > 0)
232
- .distinct(),
233
- ];
294
+ function keys(
295
+ input: ConfigKey,
296
+ options: Pick<ConfigOptions, "scope" | "prefix"> = {},
297
+ ): readonly string[] {
298
+ const scopes = object
299
+ .sequence(options.scope ?? DEFAULT_SCOPE)
300
+ .map(sharedString.trimToEmpty)
301
+ .filter(Boolean)
302
+ .distinct()
303
+ .toArray();
304
+ const prefixes =
305
+ options.prefix === undefined
306
+ ? []
307
+ : object
308
+ .sequence(options.prefix)
309
+ .map(sharedString.trimToEmpty)
310
+ .filter(Boolean)
311
+ .distinct()
312
+ .toArray();
313
+ return object
314
+ .sequence(input)
315
+ .map(sharedString.trimToEmpty)
316
+ .filter(Boolean)
317
+ .distinct()
318
+ .flatMap((key) => {
319
+ const prefixed = prefixes.length > 0 ? prefixes.map((prefix) => `${prefix}_${key}`) : [key];
320
+ return [
321
+ ...scopes.flatMap((scope) => prefixed.map((name) => `${scope}_${name}`)),
322
+ ...prefixed,
323
+ key,
324
+ ];
325
+ })
326
+ .distinct()
327
+ .toArray();
234
328
  }
235
329
 
236
330
  /**
237
331
  * Every value that resolves for `input`, in source-then-key precedence order,
238
332
  * as a LAZY sequence: nothing past the first consumed element is read, so
239
- * `values(key).first()` never spawns the Databricks CLI when the environment
333
+ * `values(key).at(0)` never spawns the Databricks CLI when the environment
240
334
  * already answered.
241
335
  *
242
336
  */
243
337
  function values(input: ConfigKey, options: ConfigOptions = {}): object.Sequence<ConfigValue> {
244
338
  const candidates = keys(input, options);
245
- const sources = object.sequence<ConfigSource>(options.sources ?? DEFAULT_SOURCES);
339
+ const sources = configSources(options);
246
340
  return object.sequence({
247
341
  *[Symbol.iterator](): Generator<ConfigValue> {
248
342
  for (const source of sources) {
249
- for (const map of read(source, options.cwd)) {
343
+ for (const map of read(source, options)) {
250
344
  for (const key of candidates) {
251
- const value = stringModule.trimToNull(map[key]);
345
+ const value = configMapValue(map[key]);
252
346
  if (value !== null) yield { key, source, value };
253
347
  }
254
348
  }
@@ -257,6 +351,27 @@ function values(input: ConfigKey, options: ConfigOptions = {}): object.Sequence<
257
351
  });
258
352
  }
259
353
 
354
+ function configSources(options: ConfigOptions): readonly ConfigSource[] {
355
+ const sources: ConfigSource[] = [
356
+ ...object.sequence<ConfigSource>(options.sources ?? DEFAULT_SOURCES).distinct(),
357
+ ];
358
+ if (options.data !== undefined && options.sources !== undefined && !sources.includes("config")) {
359
+ sources.push("config");
360
+ }
361
+ return sources;
362
+ }
363
+
364
+ function configMapValue(value: ConfigMapValue | unknown): string | null {
365
+ if (Array.isArray(value)) {
366
+ for (const entry of value) {
367
+ const resolved = sharedString.trimToNull(entry);
368
+ if (resolved !== null) return resolved;
369
+ }
370
+ return null;
371
+ }
372
+ return sharedString.trimToNull(value);
373
+ }
374
+
260
375
  /**
261
376
  * The first value that resolves for `input`, or `undefined`.
262
377
  *
@@ -267,6 +382,11 @@ export function text(input: ConfigKey, options: ConfigOptions = {}): string | un
267
382
  return values(input, options).at(0)?.value;
268
383
  }
269
384
 
385
+ /** Resolve a human-friendly name through {@link environmentKeys} and {@link text}. */
386
+ export function resolveValue(name: string, options: ConfigOptions = {}): string | undefined {
387
+ return text(environmentKeys(name), options);
388
+ }
389
+
270
390
  /**
271
391
  * The PRIMARY (fully-scoped) name for `input` - what to print in a log line or an
272
392
  * error, so the message names the variable a reader should set. Do not index
@@ -287,7 +407,7 @@ export function name(
287
407
  * Known long and short names are interchangeable; unknown names pass through.
288
408
  */
289
409
  function nodeEnvNames(nodeEnv: unknown): string[] {
290
- const name = stringModule.trimToNull(nodeEnv)?.toLowerCase();
410
+ const name = sharedString.trimToNull(nodeEnv)?.toLowerCase();
291
411
  if (!name || !/^[a-z0-9_-]+$/.test(name)) return [];
292
412
  for (const [canonical, alternatives] of Object.entries(NODE_ENV_ALTERNATIVES)) {
293
413
  const names: readonly string[] = alternatives;
@@ -298,13 +418,6 @@ function nodeEnvNames(nodeEnv: unknown): string[] {
298
418
  return [name];
299
419
  }
300
420
 
301
- function join(...parts: string[]): string {
302
- return parts
303
- .map((part) => part.trim())
304
- .filter(Boolean)
305
- .join("_");
306
- }
307
-
308
421
  /**
309
422
  * Resolve a string: `configured` when non-empty, else {@link text}, else `undefined`.
310
423
  *
@@ -319,7 +432,7 @@ export function string(
319
432
  input: ConfigKey,
320
433
  options?: ConfigOptions,
321
434
  ): string | undefined {
322
- return stringModule.trimToNull(configured) ?? text(input, options);
435
+ return sharedString.trimToNull(configured) ?? text(input, options);
323
436
  }
324
437
 
325
438
  /**
@@ -344,7 +457,10 @@ export function positiveNumber(
344
457
  fallback: number,
345
458
  options?: ConfigOptions,
346
459
  ): number {
347
- return toPositiveNumber(configured) ?? toPositiveNumber(text(input, options)) ?? fallback;
460
+ const fromConfig = positiveNumberValue.safeParse(configured);
461
+ if (fromConfig.success) return fromConfig.data;
462
+ const fromSources = positiveNumberValue.safeParse(text(input, options));
463
+ return fromSources.success ? fromSources.data : fallback;
348
464
  }
349
465
 
350
466
  /**
@@ -358,8 +474,27 @@ export function positiveInt(
358
474
  fallback: number,
359
475
  options?: ConfigOptions,
360
476
  ): number {
361
- const resolved = positiveNumber(configured, input, fallback, options);
362
- return Math.floor(resolved);
477
+ const fromConfig = positiveIntValue.safeParse(configured);
478
+ if (fromConfig.success) return fromConfig.data;
479
+ const fromSources = positiveIntValue.safeParse(text(input, options));
480
+ return fromSources.success ? fromSources.data : Math.floor(fallback);
481
+ }
482
+
483
+ /**
484
+ * Resolve a TCP port between 1 and {@link MAX_TCP_PORT}. Invalid configured or
485
+ * sourced values fall back to the caller's default, which may be a sentinel
486
+ * such as `0` when the caller uses one.
487
+ */
488
+ export function port(
489
+ configured: unknown,
490
+ input: ConfigKey,
491
+ fallback: number,
492
+ options?: ConfigOptions,
493
+ ): number {
494
+ const fromConfig = portValueSchema.safeParse(configured);
495
+ if (fromConfig.success) return fromConfig.data;
496
+ const fromSources = portValueSchema.safeParse(text(input, options));
497
+ return fromSources.success ? fromSources.data : fallback;
363
498
  }
364
499
 
365
500
  /**
@@ -372,221 +507,107 @@ export function list(
372
507
  transform?: (entry: string) => string,
373
508
  options?: ConfigOptions,
374
509
  ): string[] {
375
- const fromConfig = stringModule.parseList(configured, transform);
510
+ const fromConfig = sharedString.parseList(configured, transform);
376
511
  return fromConfig.length > 0
377
512
  ? fromConfig
378
- : stringModule.parseList(text(input, options), transform);
379
- }
380
-
381
- function toPositiveNumber(value: unknown): number | undefined {
382
- const parsed = object.toNumber(value);
383
- return parsed !== undefined && parsed > 0 ? parsed : undefined;
513
+ : sharedString.parseList(text(input, options), transform);
384
514
  }
385
515
 
386
516
  /**
387
517
  * The Databricks bundle output for `cwd` - `databricks bundle validate --output
388
518
  * json` run from the directory holding `databricks.yml`, with the config file's
389
519
  * path. A non-zero validation may still return partial JSON with usable App
390
- * config. `undefined` when bundle reads are disabled, this is not an AppKit
391
- * project, the bundle does not describe exactly one app with `config.env`, there
392
- * is no bundle, or the CLI produces no JSON.
520
+ * config. `undefined` when bundle reads are disabled, the process is production
521
+ * or a deployed App without an explicit override, there is no bundle, or the
522
+ * CLI produces no JSON.
393
523
  *
394
- * The spawn is guarded because it is expensive and usually pointless. In order:
395
- * `@databricks/appkit` must be RESOLVABLE (no AppKit, no bundle - and the probe
396
- * never evaluates the module); the bundle must describe exactly ONE app carrying
397
- * `config.env`; and AppKit's execution context must confirm this process is that
398
- * app. Only the confirmation is remembered, so a lookup during boot - before any
399
- * context exists - still resolves from the bundle and re-confirms later.
524
+ * Parsed validation output is cached by bundle path and Databricks profile.
400
525
  *
401
- * Cached once per resolved working-directory context and
402
- * `DATABRICKS_CONFIG_PROFILE` through {@link context.cached}, so repeated
403
- * lookups do not rerun validation and changing either cannot return another
404
- * context's bundle.
405
526
  */
406
527
  export function bundleFile(cwd?: string | null): ConfigFile | undefined {
407
- const production = process.env.NODE_ENV?.trim().toLowerCase() === "production";
408
528
  const override = object.toBoolean(process.env[CONFIG_BUNDLE_KEY]);
409
529
  if (override === false) return undefined;
410
- // An explicit `true` is the escape hatch for a tool that wants the bundle
411
- // without being the app (`dbx` commands, tests), so it skips the AppKit gate
412
- // entirely - but not the App/production defaults, which it also overrides.
413
- if (override === undefined) {
414
- if (production || isDatabricksAppEnv()) return undefined;
415
- if (!appKitInstalled()) return undefined;
530
+ if (
531
+ override === undefined &&
532
+ (process.env.NODE_ENV?.trim().toLowerCase() === "production" || isDatabricksAppEnv())
533
+ ) {
534
+ return undefined;
416
535
  }
417
- const profile = stringModule.trimToNull(process.env.DATABRICKS_CONFIG_PROFILE);
418
- const file = cachedConfig(["bundle", profile ?? ""], cwd, (resolved) =>
419
- loadBundleFile(resolved, profile),
420
- );
421
- if (override !== undefined || file === undefined) return file;
422
- // Already confirmed: this process is the app, and that does not stop being
423
- // true, so skip re-deriving the gate on every lookup.
424
- if (appKitContext) return file;
425
- // The app must be unambiguous. `bundleApp` is what decides "exactly one app
426
- // with `config.env`", so an empty map IS the ambiguous or app-less bundle and
427
- // needs no separate check.
428
- if (Object.keys(bundleApp(file.data)).length === 0) return undefined;
429
- confirmAppKitContext();
430
- return file;
536
+ const resolved = resolveWorkingDirectory(cwd);
537
+ return loadBundleFile(resolved, sharedString.trimToNull(process.env.DATABRICKS_CONFIG_PROFILE));
431
538
  }
432
539
 
433
- /**
434
- * The single App's literal `config.env` entries, flattened to `name -> value`.
435
- *
436
- * A `value_from` entry names a bundle resource whose id is known after
437
- * deployment, and reading it needs the resource vocabulary this module
438
- * deliberately does not have - node-appkit resolves those against its extended
439
- * {@link bundleResourceSchema}. An `apps` block with more than one app is
440
- * ambiguous (nothing here says which app this process is), so it yields nothing
441
- * rather than a guess.
442
- *
443
- * Memoized against the parsed bundle it came from, because both the gate in
444
- * {@link bundleFile} and the lookup in {@link read} ask the same question of the
445
- * same cached object - validating that payload twice per lookup would be pure
446
- * waste. A `WeakMap` keeps the entry alive exactly as long as the bundle is.
447
- */
448
- function bundleApp(input: unknown): Record<string, string> {
449
- if (!object.isRecord(input)) return bundleAppEntries(input);
450
- const cached = BUNDLE_APP_CACHE.get(input);
451
- if (cached) return cached;
452
- const result = bundleAppEntries(input);
453
- BUNDLE_APP_CACHE.set(input, result);
454
- return result;
540
+ /** The parsed `app.yaml` / `app.yml` for `cwd`, when local App config reads are enabled. */
541
+ export function appFile(cwd?: string | null): ConfigFile | undefined {
542
+ const enabled = object.toBoolean(process.env[CONFIG_APP_KEY]) ?? !isDatabricksAppEnv();
543
+ if (!enabled) return undefined;
544
+ return loadAppFile(resolveWorkingDirectory(cwd));
455
545
  }
456
546
 
457
- /** {@link bundleApp} without the memoization, so the cache has one filler. */
458
- function bundleAppEntries(input: unknown): Record<string, string> {
459
- const parsed = bundleAppsSchema.safeParse(input);
460
- if (!parsed.success) return {};
461
- const apps = parsed.data.resources?.apps;
462
- if (!apps) return {};
463
- const names = Object.keys(apps);
464
- if (names.length !== 1) return {};
465
- const entries = apps[names[0]!]?.config?.env ?? [];
466
- const result: Record<string, string> = {};
467
- for (const entry of entries) {
468
- if (!entry.name) continue;
469
- const value = resolvedString(entry.value);
470
- if (value !== null) result[entry.name] = value;
471
- }
472
- return result;
547
+ /** The single bundle App's resolved environment, when available. */
548
+ function bundleEnvironment(options: ConfigOptions): Record<string, string> | undefined {
549
+ const data = sourceData(options.bundleData) ?? bundleFile(options.cwd)?.data;
550
+ return data ? flattenBundleEnv(data) : undefined;
473
551
  }
474
552
 
475
- /**
476
- * Whether `@databricks/appkit` can be RESOLVED from this process, cached for its
477
- * lifetime.
478
- *
479
- * Resolution, not import: this answers "is this an AppKit project" without
480
- * evaluating the module, so the cheap negative (a plain library or CLI consumer
481
- * that never installed the optional peer) costs one path lookup and never boots
482
- * AppKit as a side effect of reading config. A runtime with no `createRequire`
483
- * (a browser bundle that reached this Node-only module anyway) reports absent.
484
- */
485
- const appKitInstalled = functionModule.memoize((): boolean => {
486
- try {
487
- // Indirect specifier: a bundler must not try to follow an OPTIONAL peer that
488
- // a browser build has no way to resolve.
489
- const specifier = APPKIT_MODULE;
490
- createRequire(import.meta.url).resolve(specifier);
491
- return true;
492
- } catch {
493
- return false;
494
- }
495
- });
496
-
497
- /**
498
- * AppKit's execution context once it exists, or `undefined`.
499
- *
500
- * Lazily imported (and `@vite-ignore`d) because `@databricks/appkit` is an
501
- * OPTIONAL peer this module must never make required. The import is only
502
- * attempted once {@link appKitInstalled} says there is something to import, and a
503
- * failure resolves to `undefined` rather than rejecting - a missing peer is the
504
- * expected path, not an error.
505
- *
506
- * `getExecutionContext()` THROWS until AppKit has booted, so the call is caught
507
- * and only a real context is reported. The module handle is memoized; the
508
- * context lookup is not, so a call before boot answers "not yet" and a later one
509
- * still sees the context.
510
- */
511
- const appKitModule = functionModule.memoize(async (): Promise<AppKitModule | undefined> => {
512
- if (!appKitInstalled()) return undefined;
513
- const specifier = APPKIT_MODULE;
514
- return (await import(/* @vite-ignore */ specifier).catch(() => undefined)) as
515
- AppKitModule | undefined;
516
- });
517
-
518
- /**
519
- * Latch {@link appKitContext} once AppKit's execution context exists.
520
- *
521
- * Fire-and-forget, because the caller is a SYNCHRONOUS lookup and the check
522
- * needs a lazy import. The affirmative is all that is stored: AppKit's context
523
- * does not exist until it boots, and `getExecutionContext()` throws until then,
524
- * so remembering a negative would permanently disable the bundle for a process
525
- * that is seconds away from having one. Every call before the latch closes
526
- * re-attempts, which is what makes "not available yet" retry rather than stick.
527
- */
528
- function confirmAppKitContext(): void {
529
- void appKitModule()
530
- .then((appkit) => {
531
- if (!appkit) return;
532
- try {
533
- if (appkit.getExecutionContext()?.client) appKitContext = true;
534
- } catch {
535
- // AppKit has not booted yet; a later call re-checks.
536
- }
537
- })
538
- .catch(() => undefined);
553
+ function appEnvironment(options: ConfigOptions): Record<string, string> | undefined {
554
+ const data = sourceData(options.appData) ?? appFile(options.cwd)?.data;
555
+ return data ? flattenAppEnv(data) : undefined;
539
556
  }
540
557
 
541
- /** A non-empty bundle value with every `${...}` interpolation resolved. */
542
- function resolvedString(value: unknown): string | null {
543
- const parsed = bundleValue.safeParse(value);
544
- return parsed.success ? parsed.data : null;
558
+ function sourceData(
559
+ source: ConfigFile | Record<string, unknown> | undefined,
560
+ ): Record<string, unknown> | undefined {
561
+ if (!source) return undefined;
562
+ if ("path" in source && typeof source.path === "string" && object.isRecord(source.data)) {
563
+ return source.data;
564
+ }
565
+ return source as Record<string, unknown>;
545
566
  }
546
567
 
547
- /** Parsed `.env` for `cwd`, or `{}`. Read once per resolved context. */
568
+ /** Parsed `.env` for `cwd`, or `{}`; parsed file data is cached by path. */
548
569
  function dotenv(cwd?: string | null): Record<string, string | undefined> {
549
- if (!fileSourceEnabled(CONFIG_DOTENV_KEY)) return {};
550
- const environments = nodeEnvNames(process.env.NODE_ENV);
551
- return cachedConfig(["dotenv", ...environments], cwd, (resolved) =>
552
- loadDotenv(resolved, environments),
553
- );
570
+ const enabled = object.toBoolean(process.env[CONFIG_DOTENV_KEY]) ?? !isDatabricksAppEnv();
571
+ if (!enabled) return {};
572
+ const resolved = resolveWorkingDirectory(cwd);
573
+ return loadDotenv(resolved, nodeEnvNames(process.env.NODE_ENV));
554
574
  }
555
575
 
556
- /** Cache every target directory separately within the active process context. */
557
- function cachedConfig<T>(
558
- name: readonly string[],
559
- cwd: string | null | undefined,
560
- loader: (resolved: string) => T,
561
- ): T {
562
- const active = context.getContext() ?? "";
563
- const resolved = resolve(cwd ?? ".");
564
- return context.cached(["config", active, resolved, ...name], () => loader(resolved));
565
- }
566
-
567
- /**
568
- * Maps for one source in precedence order. Bundle App config and root variables
569
- * stay separate so a first-match lookup does not parse variables after an App
570
- * value resolves.
571
- */
576
+ /** Maps for one source in precedence order. */
572
577
  function read(
573
578
  source: ConfigSource,
574
- cwd?: string,
575
- ): object.Sequence<Record<string, string | undefined>> {
579
+ options: ConfigOptions,
580
+ ): object.Sequence<Readonly<Record<string, unknown>>> {
576
581
  return object.sequence({
577
- *[Symbol.iterator](): Generator<Record<string, string | undefined>> {
582
+ *[Symbol.iterator](): Generator<Readonly<Record<string, unknown>>> {
578
583
  switch (source) {
584
+ case "config": {
585
+ const data = options.data;
586
+ if (Array.isArray(data)) {
587
+ yield* data;
588
+ } else if (data !== undefined) {
589
+ yield data as ConfigData;
590
+ }
591
+ break;
592
+ }
579
593
  case "env":
580
594
  yield process.env;
581
595
  break;
582
596
  case "dotenv":
583
- yield dotenv(cwd);
597
+ yield dotenv(options.cwd);
584
598
  break;
585
599
  case "bundle": {
586
- const bundle = bundleFile(cwd);
587
- if (bundle) yield bundleApp(bundle.data);
600
+ const environment = bundleEnvironment(options);
601
+ if (environment) yield environment;
602
+ break;
603
+ }
604
+ case "app": {
605
+ const environment = appEnvironment(options);
606
+ if (environment) yield environment;
588
607
  break;
589
608
  }
609
+ default:
610
+ throw new TypeError(`Unknown config source: ${source}`);
590
611
  }
591
612
  },
592
613
  });
@@ -598,6 +619,8 @@ function read(
598
619
  */
599
620
  function findConfigFile(cwd: string, names: readonly string[]): string | undefined {
600
621
  const start = resolve(cwd);
622
+ const key = JSON.stringify([start, ...names]);
623
+ if (configFileCache.has(key)) return configFileCache.get(key);
601
624
  const root = resolveProjectRoot(start);
602
625
  const pathFromRoot = root ? relative(root, start) : undefined;
603
626
  const boundary =
@@ -611,18 +634,17 @@ function findConfigFile(cwd: string, names: readonly string[]): string | undefin
611
634
  for (let dir = start; ; dir = dirname(dir)) {
612
635
  for (const name of names) {
613
636
  const path = resolve(dir, name);
614
- if (statSync(path)?.isFile()) return path;
637
+ if (statSync(path)?.isFile()) {
638
+ configFileCache.set(key, path);
639
+ return path;
640
+ }
615
641
  }
616
642
  if (boundary === undefined || dir === boundary) break;
617
643
  }
644
+ configFileCache.set(key, undefined);
618
645
  return undefined;
619
646
  }
620
647
 
621
- /** A recognized source override, else the caller's automatic default. */
622
- function fileSourceEnabled(key: string, fallback: boolean = !isDatabricksAppEnv()): boolean {
623
- return object.toBoolean(process.env[key]) ?? fallback;
624
- }
625
-
626
648
  function loadDotenv(
627
649
  cwd: string,
628
650
  environments: readonly string[],
@@ -630,42 +652,64 @@ function loadDotenv(
630
652
  const names = [...environments.map((name) => `${DOTENV_FILE_NAME}.${name}`), DOTENV_FILE_NAME];
631
653
  const path = findConfigFile(cwd, names);
632
654
  if (!path) return {};
633
- try {
634
- return parseEnv(readFileSync(path, "utf8"));
635
- } catch {
636
- logger.warn("failed to read dotenv file", { path });
637
- return {};
638
- }
655
+ return (
656
+ cachedRecord(JSON.stringify(["dotenv", path]), () => {
657
+ try {
658
+ return parseEnv(readFileSync(path, "utf8"));
659
+ } catch {
660
+ logger.warn("failed to read dotenv file", { path });
661
+ return {};
662
+ }
663
+ }) ?? {}
664
+ );
639
665
  }
640
666
 
641
667
  function loadBundleFile(cwd: string, profile: string | null): ConfigFile | undefined {
642
668
  const path = findConfigFile(cwd, BUNDLE_FILE_NAMES);
643
669
  if (!path) return undefined;
644
- const args = [
645
- "bundle",
646
- "validate",
647
- "--output",
648
- "json",
649
- ...(profile === null ? [] : ["--profile", profile]),
650
- ];
651
- const result = spawnSync("databricks", args, {
652
- cwd: resolve(path, ".."),
653
- encoding: "utf8",
654
- stdio: ["ignore", "pipe", "pipe"],
670
+ const data = cachedRecord(JSON.stringify(["bundle", path, profile]), () => {
671
+ const args = [
672
+ "bundle",
673
+ "validate",
674
+ "--output",
675
+ "json",
676
+ ...(profile === null ? [] : ["--profile", profile]),
677
+ ];
678
+ const result = spawnSync("databricks", args, {
679
+ cwd: resolve(path, ".."),
680
+ encoding: "utf8",
681
+ stdio: ["ignore", "pipe", "pipe"],
682
+ });
683
+ const output = sharedString.trimToNull(result.stdout);
684
+ const error = sharedString.trimToNull(result.stderr);
685
+ if (output === null) {
686
+ logger.debug("bundle validate produced no JSON", { path, status: result.status, error });
687
+ return undefined;
688
+ }
689
+ const parsed = json.parseRecord(output);
690
+ if (!parsed) {
691
+ logger.warn("failed to parse bundle validate output", { path, status: result.status });
692
+ return undefined;
693
+ }
694
+ if (result.status !== 0) {
695
+ logger.debug("using partial bundle output", { path, status: result.status, error });
696
+ }
697
+ return parsed;
655
698
  });
656
- const output = stringModule.trimToNull(result.stdout);
657
- const error = stringModule.trimToNull(result.stderr);
658
- if (output === null) {
659
- logger.debug("bundle validate produced no JSON", { path, status: result.status, error });
660
- return undefined;
661
- }
662
- const data = json.parseRecord(output);
663
- if (!data) {
664
- logger.warn("failed to parse bundle validate output", { path, status: result.status });
665
- return undefined;
666
- }
667
- if (result.status !== 0) {
668
- logger.debug("using partial bundle output", { path, status: result.status, error });
669
- }
670
- return { path, data };
699
+ return data === undefined ? undefined : { path, data };
700
+ }
701
+
702
+ function loadAppFile(cwd: string): ConfigFile | undefined {
703
+ const path = findConfigFile(cwd, APP_FILE_NAMES);
704
+ if (!path) return undefined;
705
+ const data = cachedRecord(JSON.stringify(["app", path]), () => {
706
+ try {
707
+ const parsed = parseYaml(readFileSync(path, "utf8"));
708
+ return object.isRecord(parsed) ? parsed : undefined;
709
+ } catch {
710
+ logger.warn("failed to parse app yaml", { path });
711
+ return undefined;
712
+ }
713
+ });
714
+ return data === undefined ? undefined : { path, data };
671
715
  }