Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/cuddly-mice-appear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@confts/bootstrap": patch
"confts": patch
---

Better error handling with diagnostics
18 changes: 13 additions & 5 deletions packages/bootstrap/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export type AutorunOptions<T> =

export interface StartupOptions<T = unknown> extends ResolveParams {
autorun?: AutorunOptions<T>;
onError?: (error: Error) => void;
}

// Overload: bootstrap(schema, factory)
Expand Down Expand Up @@ -94,11 +95,18 @@ export function bootstrap<

const service: Service<S, T> = {
async create(createOptions?: ResolveParams): Promise<{ server: T, config: ResolvedConfig<S> }> {
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<void> {
Expand Down
35 changes: 34 additions & 1 deletion packages/bootstrap/test/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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);
});
});
});
5 changes: 4 additions & 1 deletion packages/confts/src/errors.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Expand Down
6 changes: 4 additions & 2 deletions packages/confts/src/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ export function resolve<S extends ConftsSchema<Record<string, unknown>>>(
throw new ConfigError(
`Unsupported config file extension: ${ext}. Supported: ${supported || "none"}. Install @confts/yaml-loader for YAML support.`,
configPath,
false
false,
collector.getEvents()
);
}

Expand All @@ -54,7 +55,8 @@ export function resolve<S extends ConftsSchema<Record<string, unknown>>>(
throw new ConfigError(
`Config file not found: ${configPath}`,
configPath,
false
false,
collector.getEvents()
);
}
} else {
Expand Down
6 changes: 4 additions & 2 deletions packages/confts/src/values.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,8 @@ function resolveValue(
throw new ConfigError(
`Missing required config at '${pathStr}' (value: ${formatValue(value, sensitive)})`,
pathStr,
sensitive
sensitive,
collector.getEvents()
);
}

Expand All @@ -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()
);
}

Expand Down
14 changes: 14 additions & 0 deletions packages/confts/test/errors.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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()", () => {
Expand Down
28 changes: 28 additions & 0 deletions packages/confts/test/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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", () => {
Expand Down
34 changes: 34 additions & 0 deletions packages/confts/test/values.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down