From 81f96fdd27963a605d6f8969eb162a350cf434cd Mon Sep 17 00:00:00 2001 From: Code-Paragon Date: Sun, 30 Aug 2026 13:50:02 +0100 Subject: [PATCH 1/3] feat(backend): implement immutable SDK request context (#467) --- src/context/RequestContext.ts | 123 +++++++++ src/index.ts | 1 + tests/context/RequestContext.test.ts | 372 +++++++++++++++++++++++++++ 3 files changed, 496 insertions(+) create mode 100644 src/context/RequestContext.ts create mode 100644 tests/context/RequestContext.test.ts diff --git a/src/context/RequestContext.ts b/src/context/RequestContext.ts new file mode 100644 index 0000000..4495056 --- /dev/null +++ b/src/context/RequestContext.ts @@ -0,0 +1,123 @@ +/** + * Immutable request context with scoped metadata propagation. + * + * This class provides a framework-independent primitive for managing request-scoped + * metadata using a linked-list/parent-pointer approach. Each context derivation + * creates a new node in the chain, enabling efficient shadowing and inheritance + * without mutating parent contexts. + * + * @example + * ```ts + * const empty = RequestContext.empty(); + * const withUser = empty.with('userId', '123'); + * const withRequest = withUser.with('requestId', 'abc'); + * + * console.log(withRequest.get('userId')); // '123' + * console.log(withRequest.get('requestId')); // 'abc' + * console.log(empty.get('userId')); // undefined (empty context unchanged) + * ``` + */ +export class RequestContext = {}> { + private constructor( + private readonly parent?: RequestContext, + private readonly key?: string, + private readonly value?: any + ) {} + + /** + * Creates an empty context with no metadata. + * + * @returns A new empty RequestContext instance + */ + static empty(): RequestContext<{}> { + return new RequestContext(); + } + + /** + * Derives a new context with an additional key-value pair. + * The original context remains immutable. + * + * @template K - The key type (string literal) + * @template V - The value type + * @param key - The key to add + * @param value - The value to associate with the key + * @returns A new RequestContext with the extended type + * + * @example + * ```ts + * const ctx = RequestContext.empty(); + * const withUser = ctx.with('userId', '123'); + * // ctx is still empty, withUser has userId + * ``` + */ + with( + key: K, + value: V + ): RequestContext> { + return new RequestContext(this, key, value); + } + + /** + * Retrieves the value for a given key by traversing the parent chain. + * Returns undefined if the key does not exist in the context chain. + * + * @template K - The key type + * @param key - The key to retrieve + * @returns The value associated with the key, or undefined if not found + * + * @example + * ```ts + * const ctx = RequestContext.empty().with('userId', '123'); + * ctx.get('userId'); // '123' + * ctx.get('nonexistent'); // undefined + * ``` + */ + get(key: K): T[K] { + // Check if this node has the key + if (this.key === key) { + return this.value; + } + + // Traverse up the parent chain + if (this.parent) { + return this.parent.get(key); + } + + // Key not found in the chain + return undefined as T[K]; + } + + /** + * Checks if a key exists in the context chain. + * Distinguishes between a missing key and a key explicitly set to undefined. + * + * @param key - The key to check + * @returns true if the key exists in the context chain, false otherwise + * + * @example + * ```ts + * const ctx1 = RequestContext.empty().with('userId', '123'); + * ctx1.has('userId'); // true + * + * const ctx2 = RequestContext.empty().with('userId', undefined); + * ctx2.has('userId'); // true (explicitly set to undefined) + * + * const ctx3 = RequestContext.empty(); + * ctx3.has('userId'); // false (key not set) + * ``` + */ + has(key: string): boolean { + // Check if this node has the key + if (this.key === key) { + return true; + } + + // Traverse up the parent chain + if (this.parent) { + return this.parent.has(key); + } + + // Key not found in the chain + return false; + } +} diff --git a/src/index.ts b/src/index.ts index ad1e9d7..cbae0c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export * from "./client/index.js"; +export * from "./context/RequestContext.js"; export * from "./diagnostics/index.js"; export * from "./headers/index.js"; export * from "./middleware/index.js"; diff --git a/tests/context/RequestContext.test.ts b/tests/context/RequestContext.test.ts new file mode 100644 index 0000000..a72bd2d --- /dev/null +++ b/tests/context/RequestContext.test.ts @@ -0,0 +1,372 @@ +import { describe, it, expect } from "vitest"; +import { RequestContext } from "../../src/context/RequestContext"; + +describe("RequestContext", () => { + describe("Empty context creation", () => { + it("should create an empty context", () => { + const ctx = RequestContext.empty(); + expect(ctx).toBeDefined(); + expect(ctx.get("anyKey")).toBeUndefined(); + expect(ctx.has("anyKey")).toBe(false); + }); + + it("should return undefined for any key in empty context", () => { + const ctx = RequestContext.empty(); + expect(ctx.get("userId")).toBeUndefined(); + expect(ctx.get("requestId")).toBeUndefined(); + expect(ctx.get("")).toBeUndefined(); + }); + + it("should return false for has() on empty context", () => { + const ctx = RequestContext.empty(); + expect(ctx.has("userId")).toBe(false); + expect(ctx.has("requestId")).toBe(false); + expect(ctx.has("")).toBe(false); + }); + }); + + describe("Derivation/Inheritance", () => { + it("should inherit parent values", () => { + const parent = RequestContext.empty().with("userId", "123"); + const child = parent.with("requestId", "abc"); + + expect(child.get("userId")).toBe("123"); + expect(child.get("requestId")).toBe("abc"); + }); + + it("should inherit from multiple levels of parents", () => { + const root = RequestContext.empty().with("userId", "123"); + const level1 = root.with("requestId", "abc"); + const level2 = level1.with("sessionId", "xyz"); + + expect(level2.get("userId")).toBe("123"); + expect(level2.get("requestId")).toBe("abc"); + expect(level2.get("sessionId")).toBe("xyz"); + }); + + it("should allow child to access all parent keys", () => { + const parent = RequestContext.empty() + .with("userId", "123") + .with("requestId", "abc") + .with("traceId", "trace-123"); + const child = parent.with("newKey", "newValue"); + + expect(child.get("userId")).toBe("123"); + expect(child.get("requestId")).toBe("abc"); + expect(child.get("traceId")).toBe("trace-123"); + expect(child.get("newKey")).toBe("newValue"); + }); + }); + + describe("Immutability", () => { + it("should not mutate parent when child is derived", () => { + const parent = RequestContext.empty().with("userId", "123"); + const child = parent.with("requestId", "abc"); + + // Parent should not have the child's key + expect(parent.has("requestId")).toBe(false); + expect(parent.get("requestId")).toBeUndefined(); + + // Parent should still have its original key + expect(parent.get("userId")).toBe("123"); + }); + + it("should not affect parent when child adds multiple keys", () => { + const parent = RequestContext.empty().with("userId", "123"); + const child = parent + .with("requestId", "abc") + .with("sessionId", "xyz") + .with("traceId", "trace-123"); + + expect(parent.has("requestId")).toBe(false); + expect(parent.has("sessionId")).toBe(false); + expect(parent.has("traceId")).toBe(false); + expect(parent.get("userId")).toBe("123"); + }); + + it("should not affect grandparent when parent is modified", () => { + const grandparent = RequestContext.empty().with("userId", "123"); + const parent = grandparent.with("requestId", "abc"); + const child = parent.with("sessionId", "xyz"); + + expect(grandparent.has("requestId")).toBe(false); + expect(grandparent.has("sessionId")).toBe(false); + expect(grandparent.get("userId")).toBe("123"); + }); + + it("should preserve parent's values exactly", () => { + const parent = RequestContext.empty() + .with("userId", "123") + .with("count", 42) + .with("flag", true); + const child = parent.with("newKey", "newValue"); + + expect(parent.get("userId")).toBe("123"); + expect(parent.get("count")).toBe(42); + expect(parent.get("flag")).toBe(true); + }); + }); + + describe("Shadowing", () => { + it("should allow child to override parent's key", () => { + const parent = RequestContext.empty().with("userId", "123"); + const child = parent.with("userId", "456"); + + expect(child.get("userId")).toBe("456"); + expect(parent.get("userId")).toBe("123"); + }); + + it("should shadow parent value without affecting parent", () => { + const parent = RequestContext.empty().with("userId", "123"); + const child = parent.with("userId", "456"); + + // Child sees the shadowed value + expect(child.get("userId")).toBe("456"); + expect(child.has("userId")).toBe(true); + + // Parent is unchanged + expect(parent.get("userId")).toBe("123"); + expect(parent.has("userId")).toBe(true); + }); + + it("should allow shadowing at multiple levels", () => { + const root = RequestContext.empty().with("userId", "123"); + const level1 = root.with("userId", "456"); + const level2 = level1.with("userId", "789"); + + expect(root.get("userId")).toBe("123"); + expect(level1.get("userId")).toBe("456"); + expect(level2.get("userId")).toBe("789"); + }); + + it("should shadow with undefined value", () => { + const parent = RequestContext.empty().with("userId", "123"); + const child = parent.with("userId", undefined); + + expect(child.get("userId")).toBeUndefined(); + expect(child.has("userId")).toBe(true); + expect(parent.get("userId")).toBe("123"); + }); + }); + + describe("Sibling isolation", () => { + it("should not leak between sibling branches", () => { + const parent = RequestContext.empty().with("userId", "123"); + const branchA = parent.with("requestId", "abc"); + const branchB = parent.with("sessionId", "xyz"); + + // Branch A should not have branch B's key + expect(branchA.has("sessionId")).toBe(false); + expect(branchA.get("sessionId")).toBeUndefined(); + + // Branch B should not have branch A's key + expect(branchB.has("requestId")).toBe(false); + expect(branchB.get("requestId")).toBeUndefined(); + + // Both should have parent's key + expect(branchA.get("userId")).toBe("123"); + expect(branchB.get("userId")).toBe("123"); + }); + + it("should isolate multiple sibling branches", () => { + const parent = RequestContext.empty().with("userId", "123"); + const branchA = parent.with("keyA", "valueA"); + const branchB = parent.with("keyB", "valueB"); + const branchC = parent.with("keyC", "valueC"); + + expect(branchA.has("keyB")).toBe(false); + expect(branchA.has("keyC")).toBe(false); + expect(branchB.has("keyA")).toBe(false); + expect(branchB.has("keyC")).toBe(false); + expect(branchC.has("keyA")).toBe(false); + expect(branchC.has("keyB")).toBe(false); + + expect(branchA.get("keyA")).toBe("valueA"); + expect(branchB.get("keyB")).toBe("valueB"); + expect(branchC.get("keyC")).toBe("valueC"); + }); + + it("should isolate shadowing in siblings", () => { + const parent = RequestContext.empty().with("userId", "123"); + const branchA = parent.with("userId", "456"); + const branchB = parent.with("userId", "789"); + + expect(branchA.get("userId")).toBe("456"); + expect(branchB.get("userId")).toBe("789"); + expect(parent.get("userId")).toBe("123"); + }); + }); + + describe("Missing key behavior", () => { + it("should return undefined for missing keys", () => { + const ctx = RequestContext.empty().with("userId", "123"); + expect(ctx.get("nonexistent")).toBeUndefined(); + }); + + it("should return false for has() on missing keys", () => { + const ctx = RequestContext.empty().with("userId", "123"); + expect(ctx.has("nonexistent")).toBe(false); + }); + + it("should distinguish missing key from key set to undefined", () => { + const ctx1 = RequestContext.empty().with("userId", "123"); + const ctx2 = RequestContext.empty().with("userId", undefined); + + // ctx1 has userId set to "123" + expect(ctx1.has("userId")).toBe(true); + expect(ctx1.get("userId")).toBe("123"); + + // ctx2 has userId explicitly set to undefined + expect(ctx2.has("userId")).toBe(true); + expect(ctx2.get("userId")).toBeUndefined(); + + // Empty context does not have userId + const ctx3 = RequestContext.empty(); + expect(ctx3.has("userId")).toBe(false); + expect(ctx3.get("userId")).toBeUndefined(); + }); + + it("should distinguish missing key in parent chain", () => { + const parent = RequestContext.empty().with("userId", "123"); + const child = parent.with("requestId", "abc"); + + expect(child.has("nonexistent")).toBe(false); + expect(child.get("nonexistent")).toBeUndefined(); + }); + + it("should handle null values correctly", () => { + const ctx = RequestContext.empty().with("userId", null); + expect(ctx.has("userId")).toBe(true); + expect(ctx.get("userId")).toBeNull(); + }); + + it("should handle false values correctly", () => { + const ctx = RequestContext.empty().with("enabled", false); + expect(ctx.has("enabled")).toBe(true); + expect(ctx.get("enabled")).toBe(false); + }); + + it("should handle empty string correctly", () => { + const ctx = RequestContext.empty().with("userId", ""); + expect(ctx.has("userId")).toBe(true); + expect(ctx.get("userId")).toBe(""); + }); + + it("should handle zero correctly", () => { + const ctx = RequestContext.empty().with("count", 0); + expect(ctx.has("count")).toBe(true); + expect(ctx.get("count")).toBe(0); + }); + }); + + describe("Deep derivation chains", () => { + it("should handle deep nesting of .with() calls", () => { + const ctx = RequestContext.empty() + .with("level1", "value1") + .with("level2", "value2") + .with("level3", "value3") + .with("level4", "value4") + .with("level5", "value5"); + + expect(ctx.get("level1")).toBe("value1"); + expect(ctx.get("level2")).toBe("value2"); + expect(ctx.get("level3")).toBe("value3"); + expect(ctx.get("level4")).toBe("value4"); + expect(ctx.get("level5")).toBe("value5"); + }); + + it("should maintain inheritance through deep chains", () => { + const ctx = RequestContext.empty() + .with("userId", "123") + .with("requestId", "abc") + .with("sessionId", "xyz") + .with("traceId", "trace-123") + .with("spanId", "span-456"); + + expect(ctx.get("userId")).toBe("123"); + expect(ctx.get("requestId")).toBe("abc"); + expect(ctx.get("sessionId")).toBe("xyz"); + expect(ctx.get("traceId")).toBe("trace-123"); + expect(ctx.get("spanId")).toBe("span-456"); + }); + + it("should handle shadowing in deep chains", () => { + const ctx = RequestContext.empty() + .with("userId", "123") + .with("userId", "456") + .with("userId", "789"); + + expect(ctx.get("userId")).toBe("789"); + }); + + it("should preserve immutability in deep chains", () => { + const level1 = RequestContext.empty().with("key1", "value1"); + const level2 = level1.with("key2", "value2"); + const level3 = level2.with("key3", "value3"); + const level4 = level3.with("key4", "value4"); + const level5 = level4.with("key5", "value5"); + + expect(level1.has("key2")).toBe(false); + expect(level1.has("key3")).toBe(false); + expect(level1.has("key4")).toBe(false); + expect(level1.has("key5")).toBe(false); + + expect(level2.has("key3")).toBe(false); + expect(level2.has("key4")).toBe(false); + expect(level2.has("key5")).toBe(false); + + expect(level3.has("key4")).toBe(false); + expect(level3.has("key5")).toBe(false); + + expect(level4.has("key5")).toBe(false); + }); + + it("should handle complex value types in deep chains", () => { + const ctx = RequestContext.empty() + .with("user", { id: "123", name: "John" }) + .with("metadata", { tags: ["tag1", "tag2"], flags: { active: true } }) + .with("config", { retries: 3, timeout: 5000 }); + + expect(ctx.get("user")).toEqual({ id: "123", name: "John" }); + expect(ctx.get("metadata")).toEqual({ + tags: ["tag1", "tag2"], + flags: { active: true }, + }); + expect(ctx.get("config")).toEqual({ retries: 3, timeout: 5000 }); + }); + }); + + describe("Type safety", () => { + it("should maintain type information through derivation", () => { + const ctx = RequestContext.empty() + .with("userId", "123" as string) + .with("count", 42 as number) + .with("active", true as boolean); + + const userId: string = ctx.get("userId"); + const count: number = ctx.get("count"); + const active: boolean = ctx.get("active"); + + expect(userId).toBe("123"); + expect(count).toBe(42); + expect(active).toBe(true); + }); + + it("should allow type narrowing with generics", () => { + type UserContext = { + userId: string; + requestId: string; + }; + + const ctx = RequestContext.empty() + .with("userId", "123") + .with("requestId", "abc"); + + const userId: string = ctx.get("userId"); + const requestId: string = ctx.get("requestId"); + + expect(userId).toBe("123"); + expect(requestId).toBe("abc"); + }); + }); +}); From 59e27bda2ece3e1d2a7eac36299b7ca0e526848a Mon Sep 17 00:00:00 2001 From: Code-Paragon Date: Sun, 30 Aug 2026 13:58:27 +0100 Subject: [PATCH 2/3] feat(backend): implement typed SDK cache policy evaluator (#463) --- src/cache/CachePolicyEvaluator.ts | 109 ++++++ src/index.ts | 1 + tests/cache/CachePolicyEvaluator.test.ts | 461 +++++++++++++++++++++++ 3 files changed, 571 insertions(+) create mode 100644 src/cache/CachePolicyEvaluator.ts create mode 100644 tests/cache/CachePolicyEvaluator.test.ts diff --git a/src/cache/CachePolicyEvaluator.ts b/src/cache/CachePolicyEvaluator.ts new file mode 100644 index 0000000..df38f18 --- /dev/null +++ b/src/cache/CachePolicyEvaluator.ts @@ -0,0 +1,109 @@ +/** + * Cache policy configuration with stale-while-revalidate semantics. + */ +export interface CachePolicy { + /** Time-to-live in milliseconds before the entry is considered stale */ + ttlMs: number; + /** Additional time in milliseconds during which stale entries can be served while revalidating */ + staleWhileRevalidateMs: number; +} + +/** + * Metadata for a cache entry. + */ +export interface CacheEntryMetadata { + /** Timestamp when the entry was stored (milliseconds since epoch) */ + storedAt: number; +} + +/** + * Decision result from cache policy evaluation. + */ +export interface CacheDecision { + /** The state of the cache entry */ + state: "fresh" | "stale" | "expired"; + /** Whether the entry can be served from cache */ + canServe: boolean; + /** Whether the entry requires revalidation */ + requiresRevalidation: boolean; +} + +/** + * Pure, deterministic cache policy evaluator with stale-while-revalidate semantics. + * + * This evaluator does not perform any actual caching or storage operations. + * It only evaluates whether a cache entry is fresh, stale, or expired based on + * the provided policy and metadata. + * + * @example + * ```ts + * const policy: CachePolicy = { ttlMs: 60000, staleWhileRevalidateMs: 30000 }; + * const metadata: CacheEntryMetadata = { storedAt: Date.now() - 30000 }; + * const decision = CachePolicyEvaluator.evaluate(policy, metadata); + * // decision.state === 'fresh' + * // decision.canServe === true + * // decision.requiresRevalidation === false + * ``` + */ +export class CachePolicyEvaluator { + /** + * Evaluates a cache entry against a policy to determine its state. + * + * @param policy - The cache policy to evaluate against + * @param metadata - The cache entry metadata + * @param nowMs - Current timestamp in milliseconds (defaults to Date.now()) + * @returns The cache decision + * @throws Error if policy values are negative + */ + static evaluate( + policy: CachePolicy, + metadata: CacheEntryMetadata, + nowMs: number = Date.now() + ): CacheDecision { + // Validate policy values + if (policy.ttlMs < 0) { + throw new Error("CachePolicy.ttlMs must be non-negative"); + } + if (policy.staleWhileRevalidateMs < 0) { + throw new Error("CachePolicy.staleWhileRevalidateMs must be non-negative"); + } + + // Calculate age of the entry + const age = nowMs - metadata.storedAt; + + // Handle future timestamps (clock skew) + if (age < 0) { + return { + state: "expired", + canServe: false, + requiresRevalidation: true, + }; + } + + // Fresh: age is within TTL + if (age <= policy.ttlMs) { + return { + state: "fresh", + canServe: true, + requiresRevalidation: false, + }; + } + + // Stale: age is within the stale-while-revalidate window + const staleWindowEnd = policy.ttlMs + policy.staleWhileRevalidateMs; + if (age <= staleWindowEnd) { + return { + state: "stale", + canServe: true, + requiresRevalidation: true, + }; + } + + // Expired: age is beyond the stale-while-revalidate window + return { + state: "expired", + canServe: false, + requiresRevalidation: true, + }; + } +} diff --git a/src/index.ts b/src/index.ts index cbae0c8..9b4e62a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +export * from "./cache/CachePolicyEvaluator.js"; export * from "./client/index.js"; export * from "./context/RequestContext.js"; export * from "./diagnostics/index.js"; diff --git a/tests/cache/CachePolicyEvaluator.test.ts b/tests/cache/CachePolicyEvaluator.test.ts new file mode 100644 index 0000000..5c28f54 --- /dev/null +++ b/tests/cache/CachePolicyEvaluator.test.ts @@ -0,0 +1,461 @@ +import { describe, it, expect } from "vitest"; +import { + CachePolicyEvaluator, + CachePolicy, + CacheEntryMetadata, +} from "../../src/cache/CachePolicyEvaluator"; + +describe("CachePolicyEvaluator", () => { + describe("Fresh entries", () => { + it("should return fresh state when age is strictly less than ttlMs", () => { + const policy: CachePolicy = { ttlMs: 60000, staleWhileRevalidateMs: 30000 }; + const metadata: CacheEntryMetadata = { storedAt: 1000 }; + const nowMs = 30000; // age = 30000, which is < 60000 + + const decision = CachePolicyEvaluator.evaluate(policy, metadata, nowMs); + + expect(decision.state).toBe("fresh"); + expect(decision.canServe).toBe(true); + expect(decision.requiresRevalidation).toBe(false); + }); + + it("should return fresh state for very recent entries", () => { + const policy: CachePolicy = { ttlMs: 60000, staleWhileRevalidateMs: 30000 }; + const metadata: CacheEntryMetadata = { storedAt: 5000 }; + const nowMs = 5100; // age = 100 + + const decision = CachePolicyEvaluator.evaluate(policy, metadata, nowMs); + + expect(decision.state).toBe("fresh"); + expect(decision.canServe).toBe(true); + expect(decision.requiresRevalidation).toBe(false); + }); + + it("should return fresh state when age is zero", () => { + const policy: CachePolicy = { ttlMs: 60000, staleWhileRevalidateMs: 30000 }; + const metadata: CacheEntryMetadata = { storedAt: 1000 }; + const nowMs = 1000; // age = 0 + + const decision = CachePolicyEvaluator.evaluate(policy, metadata, nowMs); + + expect(decision.state).toBe("fresh"); + expect(decision.canServe).toBe(true); + expect(decision.requiresRevalidation).toBe(false); + }); + + it("should return fresh state with zero ttlMs and zero age", () => { + const policy: CachePolicy = { ttlMs: 0, staleWhileRevalidateMs: 0 }; + const metadata: CacheEntryMetadata = { storedAt: 1000 }; + const nowMs = 1000; // age = 0 + + const decision = CachePolicyEvaluator.evaluate(policy, metadata, nowMs); + + expect(decision.state).toBe("fresh"); + expect(decision.canServe).toBe(true); + expect(decision.requiresRevalidation).toBe(false); + }); + }); + + describe("Exact boundary for Fresh", () => { + it("should return fresh state when age exactly equals ttlMs", () => { + const policy: CachePolicy = { ttlMs: 60000, staleWhileRevalidateMs: 30000 }; + const metadata: CacheEntryMetadata = { storedAt: 1000 }; + const nowMs = 61000; // age = 60000, which === ttlMs + + const decision = CachePolicyEvaluator.evaluate(policy, metadata, nowMs); + + expect(decision.state).toBe("fresh"); + expect(decision.canServe).toBe(true); + expect(decision.requiresRevalidation).toBe(false); + }); + + it("should return fresh state at exact boundary with zero SWR", () => { + const policy: CachePolicy = { ttlMs: 60000, staleWhileRevalidateMs: 0 }; + const metadata: CacheEntryMetadata = { storedAt: 1000 }; + const nowMs = 61000; // age = 60000, which === ttlMs + + const decision = CachePolicyEvaluator.evaluate(policy, metadata, nowMs); + + expect(decision.state).toBe("fresh"); + expect(decision.canServe).toBe(true); + expect(decision.requiresRevalidation).toBe(false); + }); + }); + + describe("Stale entries", () => { + it("should return stale state when age is within SWR window", () => { + const policy: CachePolicy = { ttlMs: 60000, staleWhileRevalidateMs: 30000 }; + const metadata: CacheEntryMetadata = { storedAt: 1000 }; + const nowMs = 70000; // age = 69000, which is > 60000 and < 90000 + + const decision = CachePolicyEvaluator.evaluate(policy, metadata, nowMs); + + expect(decision.state).toBe("stale"); + expect(decision.canServe).toBe(true); + expect(decision.requiresRevalidation).toBe(true); + }); + + it("should return stale state just past TTL", () => { + const policy: CachePolicy = { ttlMs: 60000, staleWhileRevalidateMs: 30000 }; + const metadata: CacheEntryMetadata = { storedAt: 1000 }; + const nowMs = 61001; // age = 60001, which is just past TTL + + const decision = CachePolicyEvaluator.evaluate(policy, metadata, nowMs); + + expect(decision.state).toBe("stale"); + expect(decision.canServe).toBe(true); + expect(decision.requiresRevalidation).toBe(true); + }); + + it("should return stale state in middle of SWR window", () => { + const policy: CachePolicy = { ttlMs: 60000, staleWhileRevalidateMs: 30000 }; + const metadata: CacheEntryMetadata = { storedAt: 1000 }; + const nowMs = 75000; // age = 74000, which is in the middle of SWR window + + const decision = CachePolicyEvaluator.evaluate(policy, metadata, nowMs); + + expect(decision.state).toBe("stale"); + expect(decision.canServe).toBe(true); + expect(decision.requiresRevalidation).toBe(true); + }); + + it("should return stale state with large SWR window", () => { + const policy: CachePolicy = { ttlMs: 1000, staleWhileRevalidateMs: 100000 }; + const metadata: CacheEntryMetadata = { storedAt: 1000 }; + const nowMs = 50000; // age = 49000, which is within large SWR window + + const decision = CachePolicyEvaluator.evaluate(policy, metadata, nowMs); + + expect(decision.state).toBe("stale"); + expect(decision.canServe).toBe(true); + expect(decision.requiresRevalidation).toBe(true); + }); + }); + + describe("Exact boundary for Stale", () => { + it("should return stale state when age exactly equals ttlMs + staleWhileRevalidateMs", () => { + const policy: CachePolicy = { ttlMs: 60000, staleWhileRevalidateMs: 30000 }; + const metadata: CacheEntryMetadata = { storedAt: 1000 }; + const nowMs = 91000; // age = 90000, which === ttlMs + staleWhileRevalidateMs + + const decision = CachePolicyEvaluator.evaluate(policy, metadata, nowMs); + + expect(decision.state).toBe("stale"); + expect(decision.canServe).toBe(true); + expect(decision.requiresRevalidation).toBe(true); + }); + + it("should return stale state at exact boundary with zero TTL", () => { + const policy: CachePolicy = { ttlMs: 0, staleWhileRevalidateMs: 30000 }; + const metadata: CacheEntryMetadata = { storedAt: 1000 }; + const nowMs = 31000; // age = 30000, which === ttlMs + staleWhileRevalidateMs + + const decision = CachePolicyEvaluator.evaluate(policy, metadata, nowMs); + + expect(decision.state).toBe("stale"); + expect(decision.canServe).toBe(true); + expect(decision.requiresRevalidation).toBe(true); + }); + }); + + describe("Expired entries", () => { + it("should return expired state when age is beyond SWR window", () => { + const policy: CachePolicy = { ttlMs: 60000, staleWhileRevalidateMs: 30000 }; + const metadata: CacheEntryMetadata = { storedAt: 1000 }; + const nowMs = 100000; // age = 99000, which is > 90000 + + const decision = CachePolicyEvaluator.evaluate(policy, metadata, nowMs); + + expect(decision.state).toBe("expired"); + expect(decision.canServe).toBe(false); + expect(decision.requiresRevalidation).toBe(true); + }); + + it("should return expired state just past SWR boundary", () => { + const policy: CachePolicy = { ttlMs: 60000, staleWhileRevalidateMs: 30000 }; + const metadata: CacheEntryMetadata = { storedAt: 1000 }; + const nowMs = 91001; // age = 90001, which is just past SWR boundary + + const decision = CachePolicyEvaluator.evaluate(policy, metadata, nowMs); + + expect(decision.state).toBe("expired"); + expect(decision.canServe).toBe(false); + expect(decision.requiresRevalidation).toBe(true); + }); + + it("should return expired state for very old entries", () => { + const policy: CachePolicy = { ttlMs: 60000, staleWhileRevalidateMs: 30000 }; + const metadata: CacheEntryMetadata = { storedAt: 1000 }; + const nowMs = 1000000; // age = 999000, which is way beyond SWR window + + const decision = CachePolicyEvaluator.evaluate(policy, metadata, nowMs); + + expect(decision.state).toBe("expired"); + expect(decision.canServe).toBe(false); + expect(decision.requiresRevalidation).toBe(true); + }); + + it("should return expired state when SWR is zero and age > TTL", () => { + const policy: CachePolicy = { ttlMs: 60000, staleWhileRevalidateMs: 0 }; + const metadata: CacheEntryMetadata = { storedAt: 1000 }; + const nowMs = 70000; // age = 69000, which is > TTL with no SWR + + const decision = CachePolicyEvaluator.evaluate(policy, metadata, nowMs); + + expect(decision.state).toBe("expired"); + expect(decision.canServe).toBe(false); + expect(decision.requiresRevalidation).toBe(true); + }); + + it("should return expired state when both TTL and SWR are zero and age > 0", () => { + const policy: CachePolicy = { ttlMs: 0, staleWhileRevalidateMs: 0 }; + const metadata: CacheEntryMetadata = { storedAt: 1000 }; + const nowMs = 2000; // age = 1000, which is > 0 + + const decision = CachePolicyEvaluator.evaluate(policy, metadata, nowMs); + + expect(decision.state).toBe("expired"); + expect(decision.canServe).toBe(false); + expect(decision.requiresRevalidation).toBe(true); + }); + }); + + describe("Malformed/negative policy values", () => { + it("should throw error when ttlMs is negative", () => { + const policy: CachePolicy = { ttlMs: -1, staleWhileRevalidateMs: 30000 }; + const metadata: CacheEntryMetadata = { storedAt: 1000 }; + const nowMs = 5000; + + expect(() => + CachePolicyEvaluator.evaluate(policy, metadata, nowMs) + ).toThrow("CachePolicy.ttlMs must be non-negative"); + }); + + it("should throw error when staleWhileRevalidateMs is negative", () => { + const policy: CachePolicy = { ttlMs: 60000, staleWhileRevalidateMs: -1 }; + const metadata: CacheEntryMetadata = { storedAt: 1000 }; + const nowMs = 5000; + + expect(() => + CachePolicyEvaluator.evaluate(policy, metadata, nowMs) + ).toThrow("CachePolicy.staleWhileRevalidateMs must be non-negative"); + }); + + it("should throw error when both ttlMs and staleWhileRevalidateMs are negative", () => { + const policy: CachePolicy = { ttlMs: -1, staleWhileRevalidateMs: -1 }; + const metadata: CacheEntryMetadata = { storedAt: 1000 }; + const nowMs = 5000; + + // Should throw for ttlMs first + expect(() => + CachePolicyEvaluator.evaluate(policy, metadata, nowMs) + ).toThrow("CachePolicy.ttlMs must be non-negative"); + }); + + it("should accept zero ttlMs", () => { + const policy: CachePolicy = { ttlMs: 0, staleWhileRevalidateMs: 30000 }; + const metadata: CacheEntryMetadata = { storedAt: 1000 }; + const nowMs = 5000; + + expect(() => + CachePolicyEvaluator.evaluate(policy, metadata, nowMs) + ).not.toThrow(); + }); + + it("should accept zero staleWhileRevalidateMs", () => { + const policy: CachePolicy = { ttlMs: 60000, staleWhileRevalidateMs: 0 }; + const metadata: CacheEntryMetadata = { storedAt: 1000 }; + const nowMs = 5000; + + expect(() => + CachePolicyEvaluator.evaluate(policy, metadata, nowMs) + ).not.toThrow(); + }); + + it("should accept both ttlMs and staleWhileRevalidateMs as zero", () => { + const policy: CachePolicy = { ttlMs: 0, staleWhileRevalidateMs: 0 }; + const metadata: CacheEntryMetadata = { storedAt: 1000 }; + const nowMs = 1000; + + expect(() => + CachePolicyEvaluator.evaluate(policy, metadata, nowMs) + ).not.toThrow(); + }); + }); + + describe("Future storedAt timestamps (clock skew)", () => { + it("should return expired state when storedAt is in the future", () => { + const policy: CachePolicy = { ttlMs: 60000, staleWhileRevalidateMs: 30000 }; + const metadata: CacheEntryMetadata = { storedAt: 100000 }; + const nowMs = 50000; // age = -50000 (negative, future timestamp) + + const decision = CachePolicyEvaluator.evaluate(policy, metadata, nowMs); + + expect(decision.state).toBe("expired"); + expect(decision.canServe).toBe(false); + expect(decision.requiresRevalidation).toBe(true); + }); + + it("should return expired state when storedAt is just in the future", () => { + const policy: CachePolicy = { ttlMs: 60000, staleWhileRevalidateMs: 30000 }; + const metadata: CacheEntryMetadata = { storedAt: 1001 }; + const nowMs = 1000; // age = -1 (negative, future timestamp) + + const decision = CachePolicyEvaluator.evaluate(policy, metadata, nowMs); + + expect(decision.state).toBe("expired"); + expect(decision.canServe).toBe(false); + expect(decision.requiresRevalidation).toBe(true); + }); + + it("should return expired state for large future timestamps", () => { + const policy: CachePolicy = { ttlMs: 60000, staleWhileRevalidateMs: 30000 }; + const metadata: CacheEntryMetadata = { storedAt: 999999999 }; + const nowMs = 1000; // age = -999998999 (large negative) + + const decision = CachePolicyEvaluator.evaluate(policy, metadata, nowMs); + + expect(decision.state).toBe("expired"); + expect(decision.canServe).toBe(false); + expect(decision.requiresRevalidation).toBe(true); + }); + + it("should handle clock skew regardless of policy values", () => { + const policy: CachePolicy = { ttlMs: 0, staleWhileRevalidateMs: 0 }; + const metadata: CacheEntryMetadata = { storedAt: 5000 }; + const nowMs = 1000; // age = -4000 + + const decision = CachePolicyEvaluator.evaluate(policy, metadata, nowMs); + + expect(decision.state).toBe("expired"); + expect(decision.canServe).toBe(false); + expect(decision.requiresRevalidation).toBe(true); + }); + }); + + describe("Deterministic output with injected nowMs", () => { + it("should produce consistent results with same nowMs", () => { + const policy: CachePolicy = { ttlMs: 60000, staleWhileRevalidateMs: 30000 }; + const metadata: CacheEntryMetadata = { storedAt: 1000 }; + const nowMs = 50000; + + const decision1 = CachePolicyEvaluator.evaluate(policy, metadata, nowMs); + const decision2 = CachePolicyEvaluator.evaluate(policy, metadata, nowMs); + + expect(decision1).toEqual(decision2); + }); + + it("should allow deterministic testing with static nowMs", () => { + const policy: CachePolicy = { ttlMs: 60000, staleWhileRevalidateMs: 30000 }; + const metadata: CacheEntryMetadata = { storedAt: 1000 }; + + // Test fresh state deterministically + const freshDecision = CachePolicyEvaluator.evaluate( + policy, + metadata, + 30000 + ); + expect(freshDecision.state).toBe("fresh"); + + // Test stale state deterministically + const staleDecision = CachePolicyEvaluator.evaluate( + policy, + metadata, + 70000 + ); + expect(staleDecision.state).toBe("stale"); + + // Test expired state deterministically + const expiredDecision = CachePolicyEvaluator.evaluate( + policy, + metadata, + 100000 + ); + expect(expiredDecision.state).toBe("expired"); + }); + + it("should default to Date.now() when nowMs is not provided", () => { + const policy: CachePolicy = { ttlMs: 60000, staleWhileRevalidateMs: 30000 }; + const metadata: CacheEntryMetadata = { storedAt: Date.now() - 30000 }; + + const decision = CachePolicyEvaluator.evaluate(policy, metadata); + + expect(decision.state).toBe("fresh"); + expect(decision.canServe).toBe(true); + expect(decision.requiresRevalidation).toBe(false); + }); + + it("should produce different results with different nowMs values", () => { + const policy: CachePolicy = { ttlMs: 60000, staleWhileRevalidateMs: 30000 }; + const metadata: CacheEntryMetadata = { storedAt: 1000 }; + + const decision1 = CachePolicyEvaluator.evaluate(policy, metadata, 30000); + const decision2 = CachePolicyEvaluator.evaluate(policy, metadata, 70000); + + expect(decision1.state).toBe("fresh"); + expect(decision2.state).toBe("stale"); + }); + + it("should handle edge cases with integer millisecond precision", () => { + const policy: CachePolicy = { ttlMs: 1, staleWhileRevalidateMs: 1 }; + const metadata: CacheEntryMetadata = { storedAt: 0 }; + + const decision0 = CachePolicyEvaluator.evaluate(policy, metadata, 0); + expect(decision0.state).toBe("fresh"); + + const decision1 = CachePolicyEvaluator.evaluate(policy, metadata, 1); + expect(decision1.state).toBe("fresh"); + + const decision2 = CachePolicyEvaluator.evaluate(policy, metadata, 2); + expect(decision2.state).toBe("stale"); + + const decision3 = CachePolicyEvaluator.evaluate(policy, metadata, 3); + expect(decision3.state).toBe("expired"); + + const decision4 = CachePolicyEvaluator.evaluate(policy, metadata, 4); + expect(decision4.state).toBe("expired"); + }); + }); + + describe("Type safety", () => { + it("should maintain type information for CacheDecision", () => { + const policy: CachePolicy = { ttlMs: 60000, staleWhileRevalidateMs: 30000 }; + const metadata: CacheEntryMetadata = { storedAt: 1000 }; + const nowMs = 30000; + + const decision = CachePolicyEvaluator.evaluate(policy, metadata, nowMs); + + // Type assertions to ensure type safety + const state: "fresh" | "stale" | "expired" = decision.state; + const canServe: boolean = decision.canServe; + const requiresRevalidation: boolean = decision.requiresRevalidation; + + expect(state).toBe("fresh"); + expect(canServe).toBe(true); + expect(requiresRevalidation).toBe(false); + }); + + it("should accept CachePolicy interface", () => { + const policy: CachePolicy = { + ttlMs: 60000, + staleWhileRevalidateMs: 30000, + }; + const metadata: CacheEntryMetadata = { storedAt: 1000 }; + const nowMs = 30000; + + const decision = CachePolicyEvaluator.evaluate(policy, metadata, nowMs); + + expect(decision.state).toBe("fresh"); + }); + + it("should accept CacheEntryMetadata interface", () => { + const policy: CachePolicy = { ttlMs: 60000, staleWhileRevalidateMs: 30000 }; + const metadata: CacheEntryMetadata = { storedAt: 1000 }; + const nowMs = 30000; + + const decision = CachePolicyEvaluator.evaluate(policy, metadata, nowMs); + + expect(decision.state).toBe("fresh"); + }); + }); +}); From 7aed66f95f4ec136880acc66f0cdc94f4296d2c7 Mon Sep 17 00:00:00 2001 From: Code-Paragon Date: Sun, 30 Aug 2026 14:08:04 +0100 Subject: [PATCH 3/3] feat(validation): implement lightweight runtime response validation system --- src/index.ts | 2 + src/validation/schemas.ts | 224 ++++++ src/validation/types.ts | 42 ++ tests/validation/validation.test.ts | 1069 +++++++++++++++++++++++++++ 4 files changed, 1337 insertions(+) create mode 100644 src/validation/schemas.ts create mode 100644 src/validation/types.ts create mode 100644 tests/validation/validation.test.ts diff --git a/src/index.ts b/src/index.ts index 9b4e62a..563e732 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,8 @@ export * from "./cache/CachePolicyEvaluator.js"; export * from "./client/index.js"; export * from "./context/RequestContext.js"; +export * from "./validation/schemas.js"; +export * from "./validation/types.js"; export * from "./diagnostics/index.js"; export * from "./headers/index.js"; export * from "./middleware/index.js"; diff --git a/src/validation/schemas.ts b/src/validation/schemas.ts new file mode 100644 index 0000000..770f4be --- /dev/null +++ b/src/validation/schemas.ts @@ -0,0 +1,224 @@ +import type { + Schema, + ValidationError, + ValidationResult, +} from "./types.js"; +import { MAX_DEPTH } from "./types.js"; + +/** + * Helper function to create a validation error. + */ +function createError(message: string, path: string[]): ValidationError { + return { message, path }; +} + +/** + * Helper function to check depth limit. + */ +function checkDepth(depth?: number): void { + if (depth !== undefined && depth > MAX_DEPTH) { + throw new Error( + `Validation depth limit exceeded (max: ${MAX_DEPTH}). This may indicate circular data.` + ); + } +} + +/** + * String schema - validates that input is a string. + */ +export function string(): Schema { + return { + parse(input: unknown, path: string[] = [], depth: number = 0): ValidationResult { + checkDepth(depth); + if (typeof input === "string") { + return { success: true, data: input }; + } + return { + success: false, + error: createError("Expected string", path), + }; + }, + }; +} + +/** + * Number schema - validates that input is a finite number. + */ +export function number(): Schema { + return { + parse(input: unknown, path: string[] = [], depth: number = 0): ValidationResult { + checkDepth(depth); + if (typeof input === "number" && Number.isFinite(input)) { + return { success: true, data: input }; + } + return { + success: false, + error: createError("Expected finite number", path), + }; + }, + }; +} + +/** + * Boolean schema - validates that input is a boolean. + */ +export function boolean(): Schema { + return { + parse(input: unknown, path: string[] = [], depth: number = 0): ValidationResult { + checkDepth(depth); + if (typeof input === "boolean") { + return { success: true, data: input }; + } + return { + success: false, + error: createError("Expected boolean", path), + }; + }, + }; +} + +/** + * Null schema - validates that input is null. + */ +export function nullType(): Schema { + return { + parse(input: unknown, path: string[] = [], depth: number = 0): ValidationResult { + checkDepth(depth); + if (input === null) { + return { success: true, data: input }; + } + return { + success: false, + error: createError("Expected null", path), + }; + }, + }; +} + +/** + * Literal schema - validates that input exactly matches the given value. + */ +export function literal(value: T): Schema { + return { + parse(input: unknown, path: string[] = [], depth: number = 0): ValidationResult { + checkDepth(depth); + if (input === value) { + return { success: true, data: input as T }; + } + return { + success: false, + error: createError(`Expected literal value: ${JSON.stringify(value)}`, path), + }; + }, + }; +} + +/** + * Optional schema - allows undefined or null, or validates against the underlying schema. + */ +export function optional(schema: Schema): Schema { + return { + parse(input: unknown, path: string[] = [], depth: number = 0): ValidationResult { + checkDepth(depth); + if (input === undefined || input === null) { + return { success: true, data: undefined }; + } + return schema.parse(input, path, depth); + }, + }; +} + +/** + * Array schema - validates that input is an array and each item matches the schema. + */ +export function array(itemSchema: Schema): Schema { + return { + parse(input: unknown, path: string[] = [], depth: number = 0): ValidationResult { + checkDepth(depth); + if (!Array.isArray(input)) { + return { + success: false, + error: createError("Expected array", path), + }; + } + + const result: T[] = []; + for (let i = 0; i < input.length; i++) { + const itemPath = [...path, `[${i}]`]; + const itemResult = itemSchema.parse(input[i], itemPath, depth + 1); + if (!itemResult.success) { + return itemResult; + } + result.push(itemResult.data); + } + + return { success: true, data: result }; + }, + }; +} + +/** + * Object schema - validates object shapes with explicitly declared keys. + * Unknown keys are stripped from the result. + */ +export function object>>( + shape: T +): Schema<{ [K in keyof T]: T[K] extends Schema ? V : never }> { + return { + parse(input: unknown, path: string[] = [], depth: number = 0): ValidationResult { + checkDepth(depth); + if (typeof input !== "object" || input === null || Array.isArray(input)) { + return { + success: false, + error: createError("Expected object", path), + }; + } + + const result: Record = {}; + const obj = input as Record; + + for (const key in shape) { + if (Object.prototype.hasOwnProperty.call(shape, key)) { + const fieldPath = [...path, key]; + const fieldSchema = shape[key]; + const fieldResult = fieldSchema.parse(obj[key], fieldPath, depth + 1); + if (!fieldResult.success) { + return fieldResult; + } + result[key] = fieldResult.data; + } + } + + return { success: true, data: result }; + }, + }; +} + +/** + * Union schema - tries each schema sequentially until one succeeds. + * If all fail, returns a structured union error. + */ +export function union(...schemas: Schema[]): Schema { + return { + parse(input: unknown, path: string[] = [], depth: number = 0): ValidationResult { + checkDepth(depth); + const errors: string[] = []; + + for (const schema of schemas) { + const result = schema.parse(input, path, depth); + if (result.success) { + return result; + } + errors.push(result.error.message); + } + + return { + success: false, + error: createError( + `No union member matched. Errors: ${errors.join("; ")}`, + path + ), + }; + }, + }; +} diff --git a/src/validation/types.ts b/src/validation/types.ts new file mode 100644 index 0000000..5044fb2 --- /dev/null +++ b/src/validation/types.ts @@ -0,0 +1,42 @@ +/** + * Validation error with path information. + * The path array represents the location of the error in the data structure. + * For example, ['data', 'members', '2', 'id'] serializes to 'data.members[2].id'. + */ +export interface ValidationError { + /** Human-readable error message */ + message: string; + /** Path to the invalid field in the data structure */ + path: string[]; +} + +/** + * Discriminated union for validation results. + * Either a successful validation with parsed data, or a failure with an error. + */ +export type ValidationResult = + | { success: true; data: T } + | { success: false; error: ValidationError }; + +/** + * Core schema interface for validation. + * All schemas implement this interface to provide a consistent parse method. + */ +export interface Schema { + /** + * Parse and validate the input against this schema. + * + * @param input - The unknown input to validate + * @param path - Current path in the data structure (for error reporting) + * @param depth - Current recursion depth (for DoS protection) + * @returns ValidationResult with either the parsed data or a validation error + */ + parse( + input: unknown, + path?: string[], + depth?: number + ): ValidationResult; +} + +/** Maximum recursion depth to prevent DoS attacks via circular objects */ +export const MAX_DEPTH = 20; diff --git a/tests/validation/validation.test.ts b/tests/validation/validation.test.ts new file mode 100644 index 0000000..a82e9c8 --- /dev/null +++ b/tests/validation/validation.test.ts @@ -0,0 +1,1069 @@ +import { describe, it, expect } from "vitest"; +import { + string, + number, + boolean, + nullType, + literal, + optional, + array, + object, + union, +} from "../../src/validation/schemas"; +import type { Schema, ValidationResult, ValidationError } from "../../src/validation/types"; + +describe("Primitive validation", () => { + describe("string()", () => { + it("should validate strings successfully", () => { + const schema = string(); + const result = schema.parse("hello"); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe("hello"); + } + }); + + it("should reject non-strings", () => { + const schema = string(); + const result = schema.parse(123); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected string"); + expect(result.error.path).toEqual([]); + } + }); + + it("should reject numbers", () => { + const schema = string(); + const result = schema.parse(42); + + expect(result.success).toBe(false); + }); + + it("should reject booleans", () => { + const schema = string(); + const result = schema.parse(true); + + expect(result.success).toBe(false); + }); + + it("should reject null", () => { + const schema = string(); + const result = schema.parse(null); + + expect(result.success).toBe(false); + }); + + it("should reject objects", () => { + const schema = string(); + const result = schema.parse({}); + + expect(result.success).toBe(false); + }); + + it("should reject arrays", () => { + const schema = string(); + const result = schema.parse([]); + + expect(result.success).toBe(false); + }); + + it("should accept empty strings", () => { + const schema = string(); + const result = schema.parse(""); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(""); + } + }); + }); + + describe("number()", () => { + it("should validate finite numbers successfully", () => { + const schema = number(); + const result = schema.parse(42); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(42); + } + }); + + it("should validate negative numbers", () => { + const schema = number(); + const result = schema.parse(-10); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(-10); + } + }); + + it("should validate decimal numbers", () => { + const schema = number(); + const result = schema.parse(3.14); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(3.14); + } + }); + + it("should reject Infinity", () => { + const schema = number(); + const result = schema.parse(Infinity); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected finite number"); + } + }); + + it("should reject -Infinity", () => { + const schema = number(); + const result = schema.parse(-Infinity); + + expect(result.success).toBe(false); + }); + + it("should reject NaN", () => { + const schema = number(); + const result = schema.parse(NaN); + + expect(result.success).toBe(false); + }); + + it("should reject strings", () => { + const schema = number(); + const result = schema.parse("42"); + + expect(result.success).toBe(false); + }); + + it("should reject booleans", () => { + const schema = number(); + const result = schema.parse(true); + + expect(result.success).toBe(false); + }); + + it("should reject null", () => { + const schema = number(); + const result = schema.parse(null); + + expect(result.success).toBe(false); + }); + + it("should reject objects", () => { + const schema = number(); + const result = schema.parse({}); + + expect(result.success).toBe(false); + }); + + it("should reject arrays", () => { + const schema = number(); + const result = schema.parse([]); + + expect(result.success).toBe(false); + }); + }); + + describe("boolean()", () => { + it("should validate true successfully", () => { + const schema = boolean(); + const result = schema.parse(true); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(true); + } + }); + + it("should validate false successfully", () => { + const schema = boolean(); + const result = schema.parse(false); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(false); + } + }); + + it("should reject strings", () => { + const schema = boolean(); + const result = schema.parse("true"); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected boolean"); + } + }); + + it("should reject numbers", () => { + const schema = boolean(); + const result = schema.parse(1); + + expect(result.success).toBe(false); + }); + + it("should reject null", () => { + const schema = boolean(); + const result = schema.parse(null); + + expect(result.success).toBe(false); + }); + + it("should reject objects", () => { + const schema = boolean(); + const result = schema.parse({}); + + expect(result.success).toBe(false); + }); + + it("should reject arrays", () => { + const schema = boolean(); + const result = schema.parse([]); + + expect(result.success).toBe(false); + }); + }); + + describe("nullType()", () => { + it("should validate null successfully", () => { + const schema = nullType(); + const result = schema.parse(null); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(null); + } + }); + + it("should reject strings", () => { + const schema = nullType(); + const result = schema.parse("null"); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected null"); + } + }); + + it("should reject numbers", () => { + const schema = nullType(); + const result = schema.parse(0); + + expect(result.success).toBe(false); + }); + + it("should reject booleans", () => { + const schema = nullType(); + const result = schema.parse(false); + + expect(result.success).toBe(false); + }); + + it("should reject undefined", () => { + const schema = nullType(); + const result = schema.parse(undefined); + + expect(result.success).toBe(false); + }); + + it("should reject objects", () => { + const schema = nullType(); + const result = schema.parse({}); + + expect(result.success).toBe(false); + }); + + it("should reject arrays", () => { + const schema = nullType(); + const result = schema.parse([]); + + expect(result.success).toBe(false); + }); + }); +}); + +describe("Object validation", () => { + it("should validate simple objects", () => { + const schema = object({ + name: string(), + age: number(), + }); + + const result = schema.parse({ name: "John", age: 30 }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ name: "John", age: 30 }); + } + }); + + it("should validate nested objects", () => { + const schema = object({ + user: object({ + name: string(), + age: number(), + }), + }); + + const result = schema.parse({ user: { name: "John", age: 30 } }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ user: { name: "John", age: 30 } }); + } + }); + + it("should report correct path for nested field errors", () => { + const schema = object({ + user: object({ + name: string(), + age: number(), + }), + }); + + const result = schema.parse({ user: { name: "John", age: "thirty" } }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected finite number"); + expect(result.error.path).toEqual(["user", "age"]); + } + }); + + it("should reject non-objects", () => { + const schema = object({ name: string() }); + const result = schema.parse("string"); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected object"); + expect(result.error.path).toEqual([]); + } + }); + + it("should reject arrays", () => { + const schema = object({ name: string() }); + const result = schema.parse([]); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected object"); + } + }); + + it("should reject null", () => { + const schema = object({ name: string() }); + const result = schema.parse(null); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected object"); + } + }); + + it("should strip unknown keys", () => { + const schema = object({ name: string() }); + const result = schema.parse({ name: "John", extra: "data" }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ name: "John" }); + expect("extra" in result.data).toBe(false); + } + }); + + it("should handle missing optional fields", () => { + const schema = object({ + name: string(), + age: optional(number()), + }); + + const result = schema.parse({ name: "John" }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ name: "John", age: undefined }); + } + }); + + it("should report path for top-level field errors", () => { + const schema = object({ + name: string(), + age: number(), + }); + + const result = schema.parse({ name: "John", age: "thirty" }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected finite number"); + expect(result.error.path).toEqual(["age"]); + } + }); + + it("should validate deeply nested objects", () => { + const schema = object({ + level1: object({ + level2: object({ + level3: object({ + value: string(), + }), + }), + }), + }); + + const result = schema.parse({ + level1: { + level2: { + level3: { + value: "deep", + }, + }, + }, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.level1.level2.level3.value).toBe("deep"); + } + }); +}); + +describe("Array validation", () => { + it("should validate arrays of strings", () => { + const schema = array(string()); + const result = schema.parse(["a", "b", "c"]); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual(["a", "b", "c"]); + } + }); + + it("should validate arrays of numbers", () => { + const schema = array(number()); + const result = schema.parse([1, 2, 3]); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual([1, 2, 3]); + } + }); + + it("should validate empty arrays", () => { + const schema = array(string()); + const result = schema.parse([]); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual([]); + } + }); + + it("should report exact failing index in path", () => { + const schema = array(string()); + const result = schema.parse(["a", "b", 123, "d"]); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected string"); + expect(result.error.path).toEqual(["[2]"]); + } + }); + + it("should report failing index for first element", () => { + const schema = array(number()); + const result = schema.parse(["invalid", 2, 3]); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.path).toEqual(["[0]"]); + } + }); + + it("should report failing index for last element", () => { + const schema = array(string()); + const result = schema.parse(["a", "b", "c", 123]); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.path).toEqual(["[3]"]); + } + }); + + it("should reject non-arrays", () => { + const schema = array(string()); + const result = schema.parse("not an array"); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected array"); + expect(result.error.path).toEqual([]); + } + }); + + it("should reject objects", () => { + const schema = array(string()); + const result = schema.parse({}); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected array"); + } + }); + + it("should reject null", () => { + const schema = array(string()); + const result = schema.parse(null); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected array"); + } + }); + + it("should validate nested arrays", () => { + const schema = array(array(string())); + const result = schema.parse([["a", "b"], ["c", "d"]]); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual([["a", "b"], ["c", "d"]]); + } + }); + + it("should report path for nested array errors", () => { + const schema = array(array(string())); + const result = schema.parse([["a", "b"], ["c", 123]]); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.path).toEqual(["[1]", "[1]"]); + } + }); + + it("should validate arrays of objects", () => { + const schema = array( + object({ + name: string(), + age: number(), + }) + ); + + const result = schema.parse([ + { name: "John", age: 30 }, + { name: "Jane", age: 25 }, + ]); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual([ + { name: "John", age: 30 }, + { name: "Jane", age: 25 }, + ]); + } + }); + + it("should report path for array of object errors", () => { + const schema = array( + object({ + name: string(), + age: number(), + }) + ); + + const result = schema.parse([ + { name: "John", age: 30 }, + { name: "Jane", age: "twenty-five" }, + ]); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.path).toEqual(["[1]", "age"]); + } + }); +}); + +describe("Optional values", () => { + it("should accept undefined", () => { + const schema = optional(string()); + const result = schema.parse(undefined); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(undefined); + } + }); + + it("should accept null", () => { + const schema = optional(string()); + const result = schema.parse(null); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(undefined); + } + }); + + it("should validate matching schema when provided", () => { + const schema = optional(string()); + const result = schema.parse("hello"); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe("hello"); + } + }); + + it("should reject non-matching values", () => { + const schema = optional(string()); + const result = schema.parse(123); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected string"); + } + }); + + it("should work with optional numbers", () => { + const schema = optional(number()); + const result = schema.parse(42); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(42); + } + }); + + it("should work with optional objects", () => { + const schema = optional(object({ name: string() })); + const result = schema.parse({ name: "John" }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ name: "John" }); + } + }); + + it("should work with optional arrays", () => { + const schema = optional(array(string())); + const result = schema.parse(["a", "b"]); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual(["a", "b"]); + } + }); +}); + +describe("Literal validation", () => { + it("should validate string literals", () => { + const schema = literal("hello"); + const result = schema.parse("hello"); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe("hello"); + } + }); + + it("should validate number literals", () => { + const schema = literal(42); + const result = schema.parse(42); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(42); + } + }); + + it("should validate boolean literals", () => { + const schema = literal(true); + const result = schema.parse(true); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(true); + } + }); + + it("should reject non-matching strings", () => { + const schema = literal("hello"); + const result = schema.parse("world"); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain('Expected literal value: "hello"'); + } + }); + + it("should reject non-matching numbers", () => { + const schema = literal(42); + const result = schema.parse(43); + + expect(result.success).toBe(false); + }); + + it("should reject non-matching booleans", () => { + const schema = literal(true); + const result = schema.parse(false); + + expect(result.success).toBe(false); + }); + + it("should reject wrong types", () => { + const schema = literal("hello"); + const result = schema.parse(123); + + expect(result.success).toBe(false); + }); +}); + +describe("Union validation", () => { + it("should match first successful schema", () => { + const schema = union(string(), number()); + const result = schema.parse("hello"); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe("hello"); + } + }); + + it("should match second schema if first fails", () => { + const schema = union(string(), number()); + const result = schema.parse(42); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(42); + } + }); + + it("should fail if all schemas fail", () => { + const schema = union(string(), number()); + const result = schema.parse(true); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain("No union member matched"); + expect(result.error.message).toContain("Expected string"); + expect(result.error.message).toContain("Expected finite number"); + } + }); + + it("should work with multiple schemas", () => { + const schema = union(string(), number(), boolean()); + const result = schema.parse(true); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(true); + } + }); + + it("should work with literal unions", () => { + const schema = union(literal("a"), literal("b"), literal("c")); + const result = schema.parse("b"); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe("b"); + } + }); + + it("should fail literal union with no match", () => { + const schema = union(literal("a"), literal("b"), literal("c")); + const result = schema.parse("d"); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain("No union member matched"); + } + }); + + it("should preserve path in union errors", () => { + const schema = union(string(), number()); + const result = schema.parse(true, ["field"]); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.path).toEqual(["field"]); + } + }); +}); + +describe("Rejection of unexpected types", () => { + it("should reject string when number expected", () => { + const schema = number(); + const result = schema.parse("123"); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected finite number"); + } + }); + + it("should reject number when string expected", () => { + const schema = string(); + const result = schema.parse(123); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected string"); + } + }); + + it("should reject boolean when string expected", () => { + const schema = string(); + const result = schema.parse(true); + + expect(result.success).toBe(false); + }); + + it("should reject object when array expected", () => { + const schema = array(string()); + const result = schema.parse({}); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected array"); + } + }); + + it("should reject array when object expected", () => { + const schema = object({ name: string() }); + const result = schema.parse([]); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected object"); + } + }); + + it("should reject null when string expected", () => { + const schema = string(); + const result = schema.parse(null); + + expect(result.success).toBe(false); + }); + + it("should reject undefined when string expected (without optional)", () => { + const schema = string(); + const result = schema.parse(undefined); + + expect(result.success).toBe(false); + }); +}); + +describe("Recursion depth limitation", () => { + it("should validate within depth limit", () => { + const schema = object({ + a: object({ + b: object({ + c: object({ + d: object({ + e: string(), + }), + }), + }), + }), + }); + + const result = schema.parse({ + a: { + b: { + c: { + d: { + e: "deep", + }, + }, + }, + }, + }); + + expect(result.success).toBe(true); + }); + + it("should throw error when depth exceeds limit", () => { + // Create a deeply nested structure that exceeds MAX_DEPTH (20) + let schema: Schema = string(); + for (let i = 0; i < 25; i++) { + schema = object({ value: schema }); + } + + let value: any = "deep"; + for (let i = 0; i < 25; i++) { + value = { value }; + } + + expect(() => schema.parse(value)).toThrow( + "Validation depth limit exceeded" + ); + }); + + it("should handle circular references safely via depth limit", () => { + const schema = object({ + nested: object({ + value: string(), + }), + }); + + // Create a circular reference + const circular: any = { value: "test" }; + circular.self = circular; + + // This should not cause infinite recursion due to depth limit + // But since our implementation doesn't follow circular refs, + // we test the depth limit directly + let deepSchema: Schema = string(); + for (let i = 0; i < 21; i++) { + deepSchema = object({ level: deepSchema }); + } + + let deepValue: any = "end"; + for (let i = 0; i < 21; i++) { + deepValue = { level: deepValue }; + } + + expect(() => deepSchema.parse(deepValue)).toThrow( + "Validation depth limit exceeded" + ); + }); + + it("should track depth correctly in nested arrays", () => { + const schema = array(array(array(string()))); + const result = schema.parse([[["a"]]]); + + expect(result.success).toBe(true); + }); + + it("should throw on deeply nested arrays", () => { + let schema: Schema = string(); + for (let i = 0; i < 21; i++) { + schema = array(schema); + } + + let value: any = "end"; + for (let i = 0; i < 21; i++) { + value = [value]; + } + + expect(() => schema.parse(value)).toThrow( + "Validation depth limit exceeded" + ); + }); +}); + +describe("Complex validation scenarios", () => { + it("should validate complex nested structures", () => { + const schema = object({ + users: array( + object({ + id: string(), + name: string(), + age: optional(number()), + tags: array(string()), + }) + ), + }); + + const result = schema.parse({ + users: [ + { + id: "1", + name: "John", + age: 30, + tags: ["admin", "user"], + }, + { + id: "2", + name: "Jane", + tags: ["user"], + }, + ], + }); + + expect(result.success).toBe(true); + }); + + it("should report correct path in complex nested errors", () => { + const schema = object({ + users: array( + object({ + id: string(), + name: string(), + age: optional(number()), + tags: array(string()), + }) + ), + }); + + const result = schema.parse({ + users: [ + { + id: "1", + name: "John", + age: 30, + tags: ["admin", "user"], + }, + { + id: "2", + name: "Jane", + age: "thirty", + tags: ["user"], + }, + ], + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.path).toEqual(["users", "[1]", "age"]); + } + }); + + it("should handle union with complex types", () => { + const schema = array( + union( + object({ type: literal("user"), name: string() }), + object({ type: literal("admin"), name: string(), permissions: array(string()) }) + ) + ); + + const result = schema.parse([ + { type: "user", name: "John" }, + { type: "admin", name: "Jane", permissions: ["read", "write"] }, + ]); + + expect(result.success).toBe(true); + }); + + it("should strip unknown keys in nested objects", () => { + const schema = object({ + user: object({ + name: string(), + }), + }); + + const result = schema.parse({ + user: { name: "John", extra: "data", more: "keys" }, + extra: "top", + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ user: { name: "John" } }); + expect("extra" in result.data).toBe(false); + expect("extra" in result.data.user).toBe(false); + expect("more" in result.data.user).toBe(false); + } + }); +});