@dbx-tools/core 0.6.89 → 0.6.91

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,29 +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 one App's `resources.apps.<app>.config.env` in
7
- * `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
- * Only the single app's `config.env` is consulted. Root bundle `variables` are
24
- * not: they are authoring inputs for the bundle itself (interpolated into
25
- * targets, resources, and paths), so treating one as a process setting resolves
26
- * 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.
27
23
  *
28
24
  * Node-only (`child_process`, `fs`, `process`).
29
25
  *
@@ -34,32 +30,37 @@ import { spawnSync } from "node:child_process";
34
30
  import { readFileSync } from "node:fs";
35
31
  import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
36
32
  import { parseEnv } from "node:util";
37
- import {
38
- context,
39
- json,
40
- log,
41
- object,
42
- string as sharedString,
43
- } 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";
44
35
  import { z } from "zod";
45
- import { statSync } from "./file.ts";
46
- import { root as resolveProjectRoot } from "./project.ts";
36
+ import { cachedRecord, statSync } from "./file.ts";
37
+ import { resolveWorkingDirectory, root as resolveProjectRoot } from "./project.ts";
47
38
 
48
39
  const logger = log.logger("config");
40
+ const configFileCache = new Map<string, string | undefined>();
49
41
 
50
42
  type ConfigKey = string | readonly string[];
51
43
 
44
+ export type ConfigMapValue = string | readonly string[] | null | undefined;
45
+ export type ConfigData = Readonly<Record<string, ConfigMapValue>>;
46
+
52
47
  /** Where a value may come from, consulted in the order given. */
53
- type ConfigSource = "env" | "dotenv" | "bundle";
48
+ export type ConfigSource = "config" | "env" | "dotenv" | "bundle" | "app";
54
49
 
55
- interface ConfigOptions {
50
+ export interface ConfigOptions {
56
51
  /**
57
52
  * Outermost namespaces tried before each key. Defaults to `DBX_TOOLS`.
58
53
  */
59
54
  scope?: string | readonly string[];
60
55
  /** Capability namespaces inserted after the scope and before each key. */
61
56
  prefix?: string | readonly string[];
62
- /** Sources in precedence order. Default: `env`, `dotenv`, `bundle`. */
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`. */
63
64
  sources?: ConfigSource | readonly ConfigSource[];
64
65
  /** Directory to resolve `.env` and the bundle from. Default: `process.cwd()`. */
65
66
  cwd?: string;
@@ -73,14 +74,15 @@ interface ConfigValue {
73
74
  }
74
75
 
75
76
  /** A config file found on disk, with its parsed contents. */
76
- interface ConfigFile {
77
+ export interface ConfigFile {
77
78
  path: string;
78
79
  data: Record<string, unknown>;
79
80
  }
80
81
 
81
82
  const DEFAULT_SCOPE = "DBX_TOOLS";
82
- const DEFAULT_SOURCES: readonly ConfigSource[] = ["env", "dotenv", "bundle"];
83
+ const DEFAULT_SOURCES: readonly ConfigSource[] = ["config", "env", "dotenv", "bundle", "app"];
83
84
  const BUNDLE_FILE_NAMES = ["databricks.yml", "databricks.yaml"] as const;
85
+ const APP_FILE_NAMES = ["app.yaml", "app.yml"] as const;
84
86
  const DOTENV_FILE_NAME = ".env";
85
87
  const NODE_ENV_ALTERNATIVES = {
86
88
  production: ["prod"],
@@ -99,6 +101,9 @@ const CONFIG_DOTENV_KEY = "DBX_TOOLS_CONFIG_DOTENV";
99
101
  /** Boolean environment override for Databricks bundle reads. */
100
102
  const CONFIG_BUNDLE_KEY = "DBX_TOOLS_CONFIG_BUNDLE";
101
103
 
104
+ /** Boolean environment override for Databricks App YAML reads. */
105
+ const CONFIG_APP_KEY = "DBX_TOOLS_CONFIG_APP";
106
+
102
107
  /** Exact process-environment lookup for callers that do not read local config files. */
103
108
  export const ENV_ONLY = { scope: [] as const, sources: "env" as const };
104
109
 
@@ -135,13 +140,7 @@ const databricksAppEnvSchema = z.object({
135
140
  DATABRICKS_APP_PORT: portValueSchema,
136
141
  });
137
142
 
138
- /**
139
- * The GENERIC shape of a bundle resource: a name, and whatever else the resource
140
- * type carries. Deliberately unopinionated and `passthrough()` - the concrete
141
- * resource kinds (`sql_warehouse`, `genie_space`, `postgres`, ...) are
142
- * Databricks-App concepts that belong to the package that resolves them, so
143
- * node-appkit `.extend()`s this rather than this module knowing about them.
144
- */
143
+ /** A named bundle or App resource; concrete resource fields pass through. */
145
144
  export const bundleResourceSchema = z.object({ name: valueSchema.optional() }).passthrough();
146
145
 
147
146
  export const bundleEnvEntrySchema = z.object({
@@ -156,21 +155,108 @@ export const bundleAppSchema = z.object({
156
155
  resources: z.array(bundleResourceSchema).optional(),
157
156
  });
158
157
 
159
- const bundleAppConfigEnvSchema = z
160
- .object({
161
- resources: z.object({ apps: z.record(z.string(), bundleAppSchema).optional() }).optional(),
162
- })
163
- .transform((bundle): Record<string, string> => {
164
- const apps = Object.values(bundle.resources?.apps ?? {});
165
- if (apps.length !== 1) return {};
166
- return Object.fromEntries(
167
- (apps[0]?.config?.env ?? []).flatMap((entry) => {
168
- if (!entry.name) return [];
169
- const parsed = valueSchema.safeParse(entry.value);
170
- return parsed.success ? ([[entry.name, parsed.data]] as const) : [];
171
- }),
172
- );
173
- });
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({
170
+ resources: z.object({ apps: z.record(z.string(), bundleAppSchema).optional() }).optional(),
171
+ });
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
+ }
174
260
 
175
261
  /**
176
262
  * Detect a Databricks App runtime from its required name, host, and port.
@@ -189,6 +275,21 @@ export function isDatabricksAppEnv(
189
275
  return parsed.success;
190
276
  }
191
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();
291
+ }
292
+
192
293
  /** Candidate names in scope, prefix, and input precedence order. */
193
294
  function keys(
194
295
  input: ConfigKey,
@@ -235,13 +336,13 @@ function keys(
235
336
  */
236
337
  function values(input: ConfigKey, options: ConfigOptions = {}): object.Sequence<ConfigValue> {
237
338
  const candidates = keys(input, options);
238
- const sources = object.sequence<ConfigSource>(options.sources ?? DEFAULT_SOURCES);
339
+ const sources = configSources(options);
239
340
  return object.sequence({
240
341
  *[Symbol.iterator](): Generator<ConfigValue> {
241
342
  for (const source of sources) {
242
- for (const map of read(source, options.cwd)) {
343
+ for (const map of read(source, options)) {
243
344
  for (const key of candidates) {
244
- const value = sharedString.trimToNull(map[key]);
345
+ const value = configMapValue(map[key]);
245
346
  if (value !== null) yield { key, source, value };
246
347
  }
247
348
  }
@@ -250,6 +351,27 @@ function values(input: ConfigKey, options: ConfigOptions = {}): object.Sequence<
250
351
  });
251
352
  }
252
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
+
253
375
  /**
254
376
  * The first value that resolves for `input`, or `undefined`.
255
377
  *
@@ -260,6 +382,11 @@ export function text(input: ConfigKey, options: ConfigOptions = {}): string | un
260
382
  return values(input, options).at(0)?.value;
261
383
  }
262
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
+
263
390
  /**
264
391
  * The PRIMARY (fully-scoped) name for `input` - what to print in a log line or an
265
392
  * error, so the message names the variable a reader should set. Do not index
@@ -394,10 +521,8 @@ export function list(
394
521
  * or a deployed App without an explicit override, there is no bundle, or the
395
522
  * CLI produces no JSON.
396
523
  *
397
- * Cached once per resolved working-directory context and
398
- * `DATABRICKS_CONFIG_PROFILE` through {@link context.cached}, so repeated
399
- * lookups do not rerun validation and changing either cannot return another
400
- * context's bundle.
524
+ * Parsed validation output is cached by bundle path and Databricks profile.
525
+ *
401
526
  */
402
527
  export function bundleFile(cwd?: string | null): ConfigFile | undefined {
403
528
  const override = object.toBoolean(process.env[CONFIG_BUNDLE_KEY]);
@@ -408,60 +533,81 @@ export function bundleFile(cwd?: string | null): ConfigFile | undefined {
408
533
  ) {
409
534
  return undefined;
410
535
  }
411
- const profile = sharedString.trimToNull(process.env.DATABRICKS_CONFIG_PROFILE);
412
- return cachedConfig(["bundle", profile ?? ""], cwd, (resolved) =>
413
- loadBundleFile(resolved, profile),
414
- );
536
+ const resolved = resolveWorkingDirectory(cwd);
537
+ return loadBundleFile(resolved, sharedString.trimToNull(process.env.DATABRICKS_CONFIG_PROFILE));
538
+ }
539
+
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));
545
+ }
546
+
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;
551
+ }
552
+
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;
415
556
  }
416
557
 
417
- /** The single bundle App's resolved literal environment, when available. */
418
- function bundleEnvironment(cwd?: string): Record<string, string> | undefined {
419
- const file = bundleFile(cwd);
420
- if (!file) return undefined;
421
- const parsed = bundleAppConfigEnvSchema.safeParse(file.data);
422
- return parsed.success ? parsed.data : undefined;
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>;
423
566
  }
424
567
 
425
- /** Parsed `.env` for `cwd`, or `{}`. Read once per resolved context. */
568
+ /** Parsed `.env` for `cwd`, or `{}`; parsed file data is cached by path. */
426
569
  function dotenv(cwd?: string | null): Record<string, string | undefined> {
427
570
  const enabled = object.toBoolean(process.env[CONFIG_DOTENV_KEY]) ?? !isDatabricksAppEnv();
428
571
  if (!enabled) return {};
429
- const environments = nodeEnvNames(process.env.NODE_ENV);
430
- return cachedConfig(["dotenv", ...environments], cwd, (resolved) =>
431
- loadDotenv(resolved, environments),
432
- );
433
- }
434
-
435
- /** Cache every target directory separately within the active process context. */
436
- function cachedConfig<T>(
437
- name: readonly string[],
438
- cwd: string | null | undefined,
439
- loader: (resolved: string) => T,
440
- ): T {
441
- const active = context.getContext() ?? "";
442
- const resolved = resolve(cwd ?? ".");
443
- return context.cached(["config", active, resolved, ...name], () => loader(resolved));
572
+ const resolved = resolveWorkingDirectory(cwd);
573
+ return loadDotenv(resolved, nodeEnvNames(process.env.NODE_ENV));
444
574
  }
445
575
 
446
576
  /** Maps for one source in precedence order. */
447
577
  function read(
448
578
  source: ConfigSource,
449
- cwd?: string,
450
- ): object.Sequence<Record<string, string | undefined>> {
579
+ options: ConfigOptions,
580
+ ): object.Sequence<Readonly<Record<string, unknown>>> {
451
581
  return object.sequence({
452
- *[Symbol.iterator](): Generator<Record<string, string | undefined>> {
582
+ *[Symbol.iterator](): Generator<Readonly<Record<string, unknown>>> {
453
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
+ }
454
593
  case "env":
455
594
  yield process.env;
456
595
  break;
457
596
  case "dotenv":
458
- yield dotenv(cwd);
597
+ yield dotenv(options.cwd);
459
598
  break;
460
599
  case "bundle": {
461
- const environment = bundleEnvironment(cwd);
600
+ const environment = bundleEnvironment(options);
601
+ if (environment) yield environment;
602
+ break;
603
+ }
604
+ case "app": {
605
+ const environment = appEnvironment(options);
462
606
  if (environment) yield environment;
463
607
  break;
464
608
  }
609
+ default:
610
+ throw new TypeError(`Unknown config source: ${source}`);
465
611
  }
466
612
  },
467
613
  });
@@ -473,6 +619,8 @@ function read(
473
619
  */
474
620
  function findConfigFile(cwd: string, names: readonly string[]): string | undefined {
475
621
  const start = resolve(cwd);
622
+ const key = JSON.stringify([start, ...names]);
623
+ if (configFileCache.has(key)) return configFileCache.get(key);
476
624
  const root = resolveProjectRoot(start);
477
625
  const pathFromRoot = root ? relative(root, start) : undefined;
478
626
  const boundary =
@@ -486,10 +634,14 @@ function findConfigFile(cwd: string, names: readonly string[]): string | undefin
486
634
  for (let dir = start; ; dir = dirname(dir)) {
487
635
  for (const name of names) {
488
636
  const path = resolve(dir, name);
489
- if (statSync(path)?.isFile()) return path;
637
+ if (statSync(path)?.isFile()) {
638
+ configFileCache.set(key, path);
639
+ return path;
640
+ }
490
641
  }
491
642
  if (boundary === undefined || dir === boundary) break;
492
643
  }
644
+ configFileCache.set(key, undefined);
493
645
  return undefined;
494
646
  }
495
647
 
@@ -500,42 +652,64 @@ function loadDotenv(
500
652
  const names = [...environments.map((name) => `${DOTENV_FILE_NAME}.${name}`), DOTENV_FILE_NAME];
501
653
  const path = findConfigFile(cwd, names);
502
654
  if (!path) return {};
503
- try {
504
- return parseEnv(readFileSync(path, "utf8"));
505
- } catch {
506
- logger.warn("failed to read dotenv file", { path });
507
- return {};
508
- }
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
+ );
509
665
  }
510
666
 
511
667
  function loadBundleFile(cwd: string, profile: string | null): ConfigFile | undefined {
512
668
  const path = findConfigFile(cwd, BUNDLE_FILE_NAMES);
513
669
  if (!path) return undefined;
514
- const args = [
515
- "bundle",
516
- "validate",
517
- "--output",
518
- "json",
519
- ...(profile === null ? [] : ["--profile", profile]),
520
- ];
521
- const result = spawnSync("databricks", args, {
522
- cwd: resolve(path, ".."),
523
- encoding: "utf8",
524
- 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;
525
698
  });
526
- const output = sharedString.trimToNull(result.stdout);
527
- const error = sharedString.trimToNull(result.stderr);
528
- if (output === null) {
529
- logger.debug("bundle validate produced no JSON", { path, status: result.status, error });
530
- return undefined;
531
- }
532
- const data = json.parseRecord(output);
533
- if (!data) {
534
- logger.warn("failed to parse bundle validate output", { path, status: result.status });
535
- return undefined;
536
- }
537
- if (result.status !== 0) {
538
- logger.debug("using partial bundle output", { path, status: result.status, error });
539
- }
540
- 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 };
541
715
  }
package/src/file.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  */
6
6
  import { Stats, statSync as nodeStatSync } from "node:fs";
7
7
 
8
+ const recordCache = new Map<string, Record<string, unknown> | undefined>();
9
+
8
10
  /**
9
11
  * Best-effort `fs.stat` (sync). Returns `undefined` for a blank path or when the
10
12
  * path can't be stat'd (missing, permission denied, ...), so callers can treat
@@ -18,3 +20,20 @@ export function statSync(path: string): Stats | undefined {
18
20
  }
19
21
  return undefined;
20
22
  }
23
+
24
+ /**
25
+ * Load and cache a parsed record by caller-defined source identity.
26
+ *
27
+ * Every result is cached, including empty records and `undefined`. Include every
28
+ * input that affects parsing in `key`, such as a file path plus a Databricks
29
+ * profile.
30
+ */
31
+ export function cachedRecord<T extends Record<string, unknown>>(
32
+ key: string,
33
+ loader: () => T | undefined,
34
+ ): T | undefined {
35
+ if (recordCache.has(key)) return recordCache.get(key) as T | undefined;
36
+ const value = loader();
37
+ recordCache.set(key, value);
38
+ return value;
39
+ }