@@ -1,4 +1,4 @@
-import { Type, type Static, type TObject, type TProperties } from "@sinclair/typebox";
+import { Type, Kind, type Static, type TObject, type TProperties } from "@sinclair/typebox";
const CustomModelSchema = Type.Object({
provider: Type.String(),
@@ -56,10 +56,12 @@ export const ConfigSchema = Type.Object({
});
/** Deep-partial version of ConfigSchema for validating TOML override files. */
-function partialObject<T extends TProperties>(schema: TObject<T>) {
+export function partialObject<T extends TProperties>(schema: TObject<T>) {
const partial: Record<string, unknown> = {};
for (const [key, value] of Object.entries(schema.properties)) {
- partial[key] = Type.Optional(value as any);
+ const v = value as any;
+ const inner = v[Kind] === 'Object' && v.properties ? partialObject(v) : v;
+ partial[key] = Type.Optional(inner as any);
}
return Type.Object(partial as any);
}
@@ -2,8 +2,11 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
+import { Type } from "@sinclair/typebox";
+import { Value } from "@sinclair/typebox/value";
import { ConfigError } from "../src/util/errors.js";
import { loadConfig } from "../src/config/loader.js";
+import { partialObject } from "../src/config/schema.js";
describe("config validation", () => {
let configDir: string;
@@ -126,3 +129,78 @@ describe("config validation", () => {
}
});
});
+
+describe("partialObject deep-partial behavior", () => {
+ const NestedSchema = Type.Object({
+ name: Type.String(),
+ inner: Type.Object({
+ host: Type.String(),
+ port: Type.Number(),
+ }),
+ });
+
+ const PartialNested = partialObject(NestedSchema);
+
+ test("accepts empty object (all fields optional at every level)", () => {
+ const result = Value.Check(PartialNested, {});
+ expect(result).toBe(true);
+ });
+
+ test("accepts object with nested section present but inner fields omitted", () => {
+ const result = Value.Check(PartialNested, { inner: {} });
+ expect(result).toBe(true);
+ });
+
+ test("accepts object with partial inner fields of a nested object", () => {
+ const result = Value.Check(PartialNested, { inner: { host: "localhost" } });
+ expect(result).toBe(true);
+ });
+
+ test("accepts fully specified object", () => {
+ const result = Value.Check(PartialNested, {
+ name: "test",
+ inner: { host: "localhost", port: 8080 },
+ });
+ expect(result).toBe(true);
+ });
+
+ test("rejects wrong type inside nested object", () => {
+ const result = Value.Check(PartialNested, { inner: { port: "not-a-number" } });
+ expect(result).toBe(false);
+ });
+
+ test("rejects wrong type at top level", () => {
+ const result = Value.Check(PartialNested, { name: 123 });
+ expect(result).toBe(false);
+ });
+
+ test("does not recurse into Type.Record", () => {
+ const SchemaWithRecord = Type.Object({
+ headers: Type.Record(Type.String(), Type.String()),
+ });
+ const Partial = partialObject(SchemaWithRecord);
+ // Record should remain as-is (not turned into a partial object)
+ // Valid: omitted entirely
+ expect(Value.Check(Partial, {})).toBe(true);
+ // Valid: proper record
+ expect(Value.Check(Partial, { headers: { "x-key": "val" } })).toBe(true);
+ // Invalid: wrong value type in record
+ expect(Value.Check(Partial, { headers: { "x-key": 42 } })).toBe(false);
+ });
+
+ test("handles deeply nested objects (3 levels)", () => {
+ const DeepSchema = Type.Object({
+ level1: Type.Object({
+ level2: Type.Object({
+ value: Type.Number(),
+ }),
+ }),
+ });
+ const PartialDeep = partialObject(DeepSchema);
+ expect(Value.Check(PartialDeep, {})).toBe(true);
+ expect(Value.Check(PartialDeep, { level1: {} })).toBe(true);
+ expect(Value.Check(PartialDeep, { level1: { level2: {} } })).toBe(true);
+ expect(Value.Check(PartialDeep, { level1: { level2: { value: 42 } } })).toBe(true);
+ expect(Value.Check(PartialDeep, { level1: { level2: { value: "nope" } } })).toBe(false);
+ });
+});