diff --git a/.changeset/cuddly-mice-appear.md b/.changeset/cuddly-mice-appear.md new file mode 100644 index 0000000..60e205a --- /dev/null +++ b/.changeset/cuddly-mice-appear.md @@ -0,0 +1,6 @@ +--- +"@confts/bootstrap": patch +"confts": patch +--- + +Better error handling with diagnostics diff --git a/packages/bootstrap/src/index.ts b/packages/bootstrap/src/index.ts index 79610ec..e258bb3 100644 --- a/packages/bootstrap/src/index.ts +++ b/packages/bootstrap/src/index.ts @@ -47,6 +47,7 @@ export type AutorunOptions = export interface StartupOptions extends ResolveParams { autorun?: AutorunOptions; + onError?: (error: Error) => void; } // Overload: bootstrap(schema, factory) @@ -94,11 +95,18 @@ export function bootstrap< const service: Service = { async create(createOptions?: ResolveParams): Promise<{ server: T, config: ResolvedConfig }> { - const config = resolveConfig(createOptions); - return { - server: await factory(config), - config, - }; + try { + const config = resolveConfig(createOptions); + return { + server: await factory(config), + config, + }; + } catch (error) { + if (options.onError && error instanceof Error) { + options.onError(error); + } + throw error; + } }, async run(runOptions?: RunOptions): Promise { diff --git a/packages/bootstrap/test/bootstrap.test.ts b/packages/bootstrap/test/bootstrap.test.ts index 1f7f178..88da408 100644 --- a/packages/bootstrap/test/bootstrap.test.ts +++ b/packages/bootstrap/test/bootstrap.test.ts @@ -6,7 +6,7 @@ import express from "express"; import { writeFileSync, mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { schema, field } from "confts"; +import { schema, field, ConfigError } from "confts"; import { bootstrap } from "../src"; // Mock server implementation (callback-based) @@ -349,4 +349,37 @@ describe("bootstrap()", () => { await runPromise; }); }); + + describe("onError", () => { + it("receives ConfigError with diagnostics on config failure", async () => { + const requiredSchema = schema({ + port: field({ type: z.number(), default: 3000 }), + apiKey: field({ type: z.string() }), // required, no default + }); + const mockServer = createMockServer(); + const onError = vi.fn(); + + const service = bootstrap(requiredSchema, { onError, env: {} }, () => mockServer); + + await expect(service.create()).rejects.toThrow(ConfigError); + expect(onError).toHaveBeenCalledOnce(); + const err = onError.mock.calls[0][0] as ConfigError; + expect(err).toBeInstanceOf(ConfigError); + expect(err.diagnostics).toBeDefined(); + expect(err.diagnostics!.some((d) => d.type === "sourceDecision" && d.key === "port")).toBe(true); + }); + + it("receives factory errors too", async () => { + const onError = vi.fn(); + const factoryError = new Error("factory failed"); + + const service = bootstrap(configSchema, { onError }, () => { + throw factoryError; + }); + + await expect(service.create()).rejects.toThrow("factory failed"); + expect(onError).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledWith(factoryError); + }); + }); }); diff --git a/packages/confts/src/errors.ts b/packages/confts/src/errors.ts index 2c50d92..40ade33 100644 --- a/packages/confts/src/errors.ts +++ b/packages/confts/src/errors.ts @@ -1,10 +1,13 @@ +import type { DiagnosticEvent } from "./types"; + export class ConfigError extends Error { name = "ConfigError"; constructor( message: string, public readonly path: string, - public readonly sensitive: boolean + public readonly sensitive: boolean, + public readonly diagnostics?: DiagnosticEvent[] ) { super(message); } diff --git a/packages/confts/src/resolve.ts b/packages/confts/src/resolve.ts index 582f4eb..b8b723e 100644 --- a/packages/confts/src/resolve.ts +++ b/packages/confts/src/resolve.ts @@ -42,7 +42,8 @@ export function resolve>>( throw new ConfigError( `Unsupported config file extension: ${ext}. Supported: ${supported || "none"}. Install @confts/yaml-loader for YAML support.`, configPath, - false + false, + collector.getEvents() ); } @@ -54,7 +55,8 @@ export function resolve>>( throw new ConfigError( `Config file not found: ${configPath}`, configPath, - false + false, + collector.getEvents() ); } } else { diff --git a/packages/confts/src/values.ts b/packages/confts/src/values.ts index 8e7862d..5c5e5af 100644 --- a/packages/confts/src/values.ts +++ b/packages/confts/src/values.ts @@ -198,7 +198,8 @@ function resolveValue( throw new ConfigError( `Missing required config at '${pathStr}' (value: ${formatValue(value, sensitive)})`, pathStr, - sensitive + sensitive, + collector.getEvents() ); } @@ -208,7 +209,8 @@ function resolveValue( throw new ConfigError( `Invalid config at '${pathStr}': ${result.error.issues[0]?.message} (value: ${formatValue(value, sensitive)})`, pathStr, - sensitive + sensitive, + collector.getEvents() ); } diff --git a/packages/confts/test/errors.test.ts b/packages/confts/test/errors.test.ts index 9ffa87c..fd416c2 100644 --- a/packages/confts/test/errors.test.ts +++ b/packages/confts/test/errors.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { ConfigError, formatValue } from "../src/errors"; +import type { DiagnosticEvent } from "../src/types"; describe("ConfigError", () => { it("stores path and sensitive flag", () => { @@ -14,6 +15,19 @@ describe("ConfigError", () => { const err = new ConfigError("msg", "key", false); expect(err).toBeInstanceOf(Error); }); + + it("stores diagnostics when provided", () => { + const diagnostics: DiagnosticEvent[] = [ + { type: "sourceDecision", key: "db.host", picked: "env:DB_HOST", tried: ["env:DB_HOST"] }, + ]; + const err = new ConfigError("missing value", "db.password", true, diagnostics); + expect(err.diagnostics).toEqual(diagnostics); + }); + + it("has undefined diagnostics when not provided", () => { + const err = new ConfigError("msg", "key", false); + expect(err.diagnostics).toBeUndefined(); + }); }); describe("formatValue()", () => { diff --git a/packages/confts/test/resolve.test.ts b/packages/confts/test/resolve.test.ts index a4ba88b..1bd557b 100644 --- a/packages/confts/test/resolve.test.ts +++ b/packages/confts/test/resolve.test.ts @@ -32,6 +32,19 @@ describe("resolve()", () => { const s = schema({ key: field({ type: z.string() }) }); expect(() => resolve(s, { configPath: "/nonexistent/config.json", env: {} })).toThrow(ConfigError); }); + + it("attaches diagnostics to ConfigError on missing file", () => { + const s = schema({ key: field({ type: z.string() }) }); + try { + resolve(s, { configPath: "/nonexistent/config.json", env: {} }); + expect.fail("should throw"); + } catch (e) { + expect(e).toBeInstanceOf(ConfigError); + const err = e as ConfigError; + expect(err.diagnostics).toBeDefined(); + expect(err.diagnostics!.some((d) => d.type === "loader" && d.used)).toBe(true); + } + }); }); describe("unsupported formats without loader", () => { @@ -57,6 +70,21 @@ describe("resolve()", () => { expect(() => resolve(s, { configPath, env: {} })).toThrow(ConfigError); expect(() => resolve(s, { configPath, env: {} })).toThrow(/unsupported/i); }); + + it("attaches diagnostics to ConfigError on unsupported extension", () => { + const configPath = join(tempDir, "config.toml"); + writeFileSync(configPath, "key = value"); + const s = schema({ key: field({ type: z.string() }) }); + try { + resolve(s, { configPath, env: {} }); + expect.fail("should throw"); + } catch (e) { + expect(e).toBeInstanceOf(ConfigError); + const err = e as ConfigError; + expect(err.diagnostics).toBeDefined(); + expect(err.diagnostics!.some((d) => d.type === "loader" && !d.used)).toBe(true); + } + }); }); describe("CONFIG_PATH env var", () => { diff --git a/packages/confts/test/values.test.ts b/packages/confts/test/values.test.ts index 08360c2..68f3f52 100644 --- a/packages/confts/test/values.test.ts +++ b/packages/confts/test/values.test.ts @@ -197,6 +197,40 @@ describe("resolveValues()", () => { const s = schema({ port: field({ type: z.number(), env: "PORT" }) }); expect(() => resolveValues(s, { env: { PORT: "not-a-number" } })).toThrow(ConfigError); }); + + it("attaches diagnostics to ConfigError on missing required", () => { + const s = schema({ + port: field({ type: z.number(), default: 3000 }), + host: field({ type: z.string(), env: "HOST" }), + }); + try { + resolveValues(s, { env: {} }); + expect.fail("should throw"); + } catch (e) { + expect(e).toBeInstanceOf(ConfigError); + const err = e as ConfigError; + expect(err.diagnostics).toBeDefined(); + // port resolved before host failed + expect(err.diagnostics!.some((d) => d.type === "sourceDecision" && d.key === "port")).toBe(true); + } + }); + + it("attaches diagnostics to ConfigError on validation fail", () => { + const s = schema({ + host: field({ type: z.string(), default: "localhost" }), + port: field({ type: z.number(), env: "PORT" }), + }); + try { + resolveValues(s, { env: { PORT: "not-a-number" } }); + expect.fail("should throw"); + } catch (e) { + expect(e).toBeInstanceOf(ConfigError); + const err = e as ConfigError; + expect(err.diagnostics).toBeDefined(); + // host resolved before port failed validation + expect(err.diagnostics!.some((d) => d.type === "sourceDecision" && d.key === "host")).toBe(true); + } + }); }); describe("secretsPath", () => {