diff --git a/README.md b/README.md index 99f5732..b911c33 100644 --- a/README.md +++ b/README.md @@ -613,6 +613,10 @@ console.log(btcRange.toString({ preferredUnit: "sat" })) // "100,000 sats - 1,00 ## Other features +### Zod Integration + +For input validation and parsing, see [`@thesis-co/cent-zod`](./packages/cent-zod) which provides Zod schemas for all `cent` types. + ### Currency support `cent` includes comprehensive currency metadata for accurate formatting: diff --git a/packages/cent-zod/README.md b/packages/cent-zod/README.md new file mode 100644 index 0000000..b9c8457 --- /dev/null +++ b/packages/cent-zod/README.md @@ -0,0 +1,84 @@ +# @thesis-co/cent-zod + +Zod schemas for parsing and validating `@thesis-co/cent` types. + +```bash +pnpm add @thesis-co/cent-zod +``` + +## Schemas + +### zMoney + +```ts +import { zMoney, zMoneyString } from "@thesis-co/cent-zod" + +zMoneyString.parse("$100.50") // MoneyClass + +// With constraints +zMoney({ + currency: "USD", + min: "$0.50", + max: "$10000", + positive: true, +}) +``` + +### zPrice + +```ts +import { zPrice } from "@thesis-co/cent-zod" + +zPrice().parse({ numerator: "$50,000", denominator: "1 BTC" }) +zPrice().parse(["$50,000", "1 BTC"]) + +// Currency constraints +zPrice("USD", "BTC") +``` + +### zExchangeRate + +```ts +import { zExchangeRate } from "@thesis-co/cent-zod" + +zExchangeRate("USD", "EUR").parse({ base: "USD", quote: "EUR", rate: "0.92" }) + +// With staleness check +zExchangeRate({ base: "BTC", quote: "USD", maxAge: 60000 }) +``` + +### zPriceRange + +```ts +import { zPriceRange } from "@thesis-co/cent-zod" + +zPriceRange().parse("$50 - $100") +zPriceRange().parse({ min: "$50", max: "$100" }) + +// With constraints +zPriceRange({ + currency: "USD", + bounds: { min: "$0", max: "$10000" }, + minSpan: "$10", +}) +``` + +### zCurrency + +```ts +import { zCurrency } from "@thesis-co/cent-zod" + +zCurrency().parse("USD") // Currency object +zCurrency({ allowed: ["USD", "EUR", "GBP"] }) +zCurrency({ type: "crypto" }) +``` + +## Type Inference + +```ts +import { z } from "zod" +import { zMoney } from "@thesis-co/cent-zod" + +const schema = zMoney("USD") +type USDMoney = z.infer // MoneyClass +``` diff --git a/packages/cent-zod/jest.config.js b/packages/cent-zod/jest.config.js new file mode 100644 index 0000000..4e45349 --- /dev/null +++ b/packages/cent-zod/jest.config.js @@ -0,0 +1,14 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/test/**/*.test.ts'], + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: './tsconfig.json', + }, + ], + }, +} diff --git a/packages/cent-zod/package.json b/packages/cent-zod/package.json new file mode 100644 index 0000000..c673ebb --- /dev/null +++ b/packages/cent-zod/package.json @@ -0,0 +1,46 @@ +{ + "name": "@thesis-co/cent-zod", + "version": "0.0.1", + "description": "Zod schemas for validating and parsing @thesis-co/cent types", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "https://github.com/thesis/cent.git", + "directory": "packages/cent-zod" + }, + "keywords": [ + "zod", + "validation", + "finance", + "currency", + "money", + "schema" + ], + "author": "Matt Luongo (@mhluongo)", + "publishConfig": { + "access": "public" + }, + "scripts": { + "lint": "pnpx @biomejs/biome check", + "lint:fix": "pnpx @biomejs/biome check --write", + "build": "tsc", + "test": "jest", + "prepublishOnly": "pnpm run build && pnpm run test && pnpm run lint" + }, + "devDependencies": { + "@thesis-co/cent": "workspace:*", + "@types/jest": "^29.5.12", + "@types/node": "^20.11.24", + "jest": "^29.7.0", + "ts-jest": "^29.1.2", + "zod": "^4.0.0" + }, + "peerDependencies": { + "@thesis-co/cent": ">=0.0.5", + "zod": ">=4.0.0" + } +} diff --git a/packages/cent-zod/src/index.ts b/packages/cent-zod/src/index.ts new file mode 100644 index 0000000..dfef1d6 --- /dev/null +++ b/packages/cent-zod/src/index.ts @@ -0,0 +1,38 @@ +// Common schemas and utilities +export { + zBigIntString, + zDecimalString, + zFixedPointJSON, + zNonNegativeBigIntString, + zRationalNumberJSON, +} from "./schemas/common" +export type { ZCurrencyOptions } from "./schemas/currency" +// Currency schemas +export { + getValidCurrencyCodes, + zCurrency, + zCurrencyCode, + zCurrencyObject, +} from "./schemas/currency" +export type { ZExchangeRateOptions } from "./schemas/exchange-rate" +// Exchange rate schemas +export { + zExchangeRate, + zExchangeRateCompact, + zExchangeRateJSON, + zExchangeRateSource, +} from "./schemas/exchange-rate" +export type { ZMoneyOptions } from "./schemas/money" +// Money schemas +export { zMoney, zMoneyJSON, zMoneyString } from "./schemas/money" +export type { ZPriceOptions } from "./schemas/price" +// Price schemas +export { zPrice, zPriceFromObject, zPriceFromTuple } from "./schemas/price" +export type { ZPriceRangeOptions } from "./schemas/price-range" +// Price range schemas +export { + zPriceRange, + zPriceRangeJSON, + zPriceRangeObject, + zPriceRangeString, +} from "./schemas/price-range" diff --git a/packages/cent-zod/src/schemas/common.ts b/packages/cent-zod/src/schemas/common.ts new file mode 100644 index 0000000..389a86a --- /dev/null +++ b/packages/cent-zod/src/schemas/common.ts @@ -0,0 +1,42 @@ +import { z } from "zod" + +/** + * Schema for bigint values serialized as strings + */ +export const zBigIntString = z + .string() + .regex(/^-?\d+$/, "Must be a valid integer string") + .transform((val) => BigInt(val)) + +/** + * Schema for non-negative bigint values + */ +export const zNonNegativeBigIntString = z + .string() + .regex(/^\d+$/, "Must be a valid non-negative integer string") + .transform((val) => BigInt(val)) + +/** + * Schema for FixedPoint JSON representation + * Transforms to { amount: bigint, decimals: bigint } + */ +export const zFixedPointJSON = z.object({ + amount: zBigIntString, + decimals: zNonNegativeBigIntString, +}) + +/** + * Schema for RationalNumber JSON representation + * Validates { p: string, q: string } (no transform - Money.fromJSON handles conversion) + */ +export const zRationalNumberJSON = z.object({ + p: z.string().regex(/^-?\d+$/, "Must be a valid integer string"), + q: z.string().regex(/^-?\d+$/, "Must be a valid integer string"), +}) + +/** + * Schema for decimal string input (e.g., "123.45") + */ +export const zDecimalString = z + .string() + .regex(/^-?\d+(\.\d+)?$/, "Must be a valid decimal string") diff --git a/packages/cent-zod/src/schemas/currency.ts b/packages/cent-zod/src/schemas/currency.ts new file mode 100644 index 0000000..a44a726 --- /dev/null +++ b/packages/cent-zod/src/schemas/currency.ts @@ -0,0 +1,128 @@ +import { type Currency, currencies, getCurrencyFromCode } from "@thesis-co/cent" +import { z } from "zod" +import { zNonNegativeBigIntString } from "./common" + +/** + * Schema that validates a currency code string and transforms to Currency object + */ +export const zCurrencyCode = z.string().transform((code, ctx) => { + try { + return getCurrencyFromCode(code) + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Unknown currency code: ${code}`, + }) + return z.NEVER + } +}) + +/** + * Schema for full Currency object representation (validation only, no transform) + */ +export const zCurrencyObject = z.object({ + name: z.string(), + code: z.string(), + decimals: z.union([z.bigint(), zNonNegativeBigIntString]), + symbol: z.string(), + fractionalUnit: z + .union([ + z.string(), + z.array(z.string()), + z.record(z.string(), z.union([z.string(), z.array(z.string())])), + ]) + .optional(), + iso4217Support: z.boolean().optional(), +}) + +/** + * Options for zCurrency schema + */ +export interface ZCurrencyOptions { + /** Only allow these currency codes */ + allowed?: string[] + /** Deny these currency codes */ + denied?: string[] + /** Filter by currency type */ + type?: "fiat" | "crypto" | "all" +} + +/** + * Create a currency validation schema + * + * @example + * ```ts + * // Any valid currency + * const schema = zCurrency() + * schema.parse("USD") // Returns USD Currency object + * + * // Only specific currencies + * const usdEurSchema = zCurrency({ allowed: ["USD", "EUR"] }) + * + * // Only fiat currencies + * const fiatSchema = zCurrency({ type: "fiat" }) + * ``` + */ +export function zCurrency(options?: ZCurrencyOptions) { + return z.string().transform((code, ctx) => { + const upperCode = code.toUpperCase() + + // Check allowlist + if (options?.allowed && !options.allowed.includes(upperCode)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Currency ${upperCode} is not in allowed list: ${options.allowed.join(", ")}`, + }) + return z.NEVER + } + + // Check denylist + if (options?.denied?.includes(upperCode)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Currency ${upperCode} is not allowed`, + }) + return z.NEVER + } + + // Get the currency + let currency: Currency + try { + currency = getCurrencyFromCode(upperCode) + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Unknown currency code: ${code}`, + }) + return z.NEVER + } + + // Check type filter + if (options?.type && options.type !== "all") { + const isFiat = currency.iso4217Support === true + if (options.type === "fiat" && !isFiat) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Currency ${upperCode} is not a fiat currency`, + }) + return z.NEVER + } + if (options.type === "crypto" && isFiat) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Currency ${upperCode} is not a cryptocurrency`, + }) + return z.NEVER + } + } + + return currency + }) +} + +/** + * Get all valid currency codes + */ +export function getValidCurrencyCodes(): string[] { + return Object.keys(currencies) +} diff --git a/packages/cent-zod/src/schemas/exchange-rate.ts b/packages/cent-zod/src/schemas/exchange-rate.ts new file mode 100644 index 0000000..4c70468 --- /dev/null +++ b/packages/cent-zod/src/schemas/exchange-rate.ts @@ -0,0 +1,177 @@ +import { + ExchangeRate, + FixedPointNumber, + getCurrencyFromCode, +} from "@thesis-co/cent" +import { z } from "zod" +import { zDecimalString, zFixedPointJSON } from "./common" +import { zCurrencyObject } from "./currency" + +/** + * Schema for ExchangeRateSource + */ +export const zExchangeRateSource = z.object({ + name: z.string(), + priority: z.number(), + reliability: z.number().min(0).max(1), +}) + +/** + * Schema for ExchangeRate from full JSON representation + */ +export const zExchangeRateJSON = z + .object({ + baseCurrency: z.union([zCurrencyObject, z.string()]), + quoteCurrency: z.union([zCurrencyObject, z.string()]), + rate: z.union([zFixedPointJSON, zDecimalString]), + timestamp: z.string().optional(), + source: zExchangeRateSource.optional(), + }) + .transform((data, ctx) => { + try { + const baseCurrency = + typeof data.baseCurrency === "string" + ? getCurrencyFromCode(data.baseCurrency) + : data.baseCurrency + const quoteCurrency = + typeof data.quoteCurrency === "string" + ? getCurrencyFromCode(data.quoteCurrency) + : data.quoteCurrency + + let rate: FixedPointNumber + if (typeof data.rate === "string") { + rate = FixedPointNumber.fromDecimalString(data.rate) + } else { + rate = new FixedPointNumber(data.rate.amount, data.rate.decimals) + } + + return new ExchangeRate({ + baseCurrency, + quoteCurrency, + rate, + timestamp: data.timestamp, + source: data.source, + }) + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Invalid exchange rate: ${error instanceof Error ? error.message : "Unknown error"}`, + }) + return z.NEVER + } + }) + +/** + * Schema for compact ExchangeRate input + * e.g., { base: "USD", quote: "EUR", rate: "0.92" } + */ +export const zExchangeRateCompact = z + .object({ + base: z.string(), + quote: z.string(), + rate: z.string(), + timestamp: z.string().optional(), + }) + .transform((data, ctx) => { + try { + const baseCurrency = getCurrencyFromCode(data.base) + const quoteCurrency = getCurrencyFromCode(data.quote) + const rate = FixedPointNumber.fromDecimalString(data.rate) + + return new ExchangeRate({ + baseCurrency, + quoteCurrency, + rate, + timestamp: data.timestamp, + }) + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Invalid exchange rate: ${error instanceof Error ? error.message : "Unknown error"}`, + }) + return z.NEVER + } + }) + +/** + * Options for zExchangeRate schema + */ +export interface ZExchangeRateOptions { + /** Expected base currency */ + base?: string + /** Expected quote currency */ + quote?: string + /** Maximum age of timestamp in milliseconds */ + maxAge?: number +} + +/** + * Create an ExchangeRate validation schema with optional constraints + * + * @example + * ```ts + * // Basic - any currencies + * const schema = zExchangeRate() + * schema.parse({ base: "USD", quote: "EUR", rate: "0.92" }) + * + * // With currency constraints + * const usdEurSchema = zExchangeRate("USD", "EUR") + * + * // With staleness check + * const freshRateSchema = zExchangeRate({ + * base: "BTC", + * quote: "USD", + * maxAge: 60000, // 1 minute + * }) + * ``` + */ +export function zExchangeRate( + baseOrOptions?: string | ZExchangeRateOptions, + quote?: string, +) { + const options: ZExchangeRateOptions = + typeof baseOrOptions === "string" + ? { base: baseOrOptions, quote } + : (baseOrOptions ?? {}) + + const baseSchema = z.union([zExchangeRateJSON, zExchangeRateCompact]) + + if (!options.base && !options.quote && !options.maxAge) { + return baseSchema + } + + return baseSchema.transform((rate, ctx) => { + // Validate base currency + if (options.base && rate.baseCurrency.code !== options.base) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Expected base currency ${options.base}, got ${rate.baseCurrency.code}`, + }) + return z.NEVER + } + + // Validate quote currency + if (options.quote && rate.quoteCurrency.code !== options.quote) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Expected quote currency ${options.quote}, got ${rate.quoteCurrency.code}`, + }) + return z.NEVER + } + + // Validate timestamp age + if (options.maxAge && rate.timestamp) { + const timestampMs = Number(rate.timestamp) * 1000 + const age = Date.now() - timestampMs + if (age > options.maxAge) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Exchange rate is stale: ${age}ms old (max: ${options.maxAge}ms)`, + }) + return z.NEVER + } + } + + return rate + }) +} diff --git a/packages/cent-zod/src/schemas/money.ts b/packages/cent-zod/src/schemas/money.ts new file mode 100644 index 0000000..6e337c8 --- /dev/null +++ b/packages/cent-zod/src/schemas/money.ts @@ -0,0 +1,183 @@ +import { + type Currency, + getCurrencyFromCode, + Money, + MoneyClass, +} from "@thesis-co/cent" +import { z } from "zod" +import { zDecimalString, zRationalNumberJSON } from "./common" +import { zCurrencyObject } from "./currency" + +/** + * Schema for MoneyAmount JSON: + * - Decimal string: "100.50" + * - Rational number: { p: "10050", q: "100" } + */ +const zMoneyAmountJSON = z.union([zDecimalString, zRationalNumberJSON]) + +/** + * Schema for Money JSON object representation + * Transforms to Money instance + */ +export const zMoneyJSON = z + .object({ + currency: z.union([zCurrencyObject, z.string()]), + amount: zMoneyAmountJSON, + }) + .transform((data) => { + return MoneyClass.fromJSON(data) + }) + +/** + * Schema that parses money string format (e.g., "$100.50", "100 USD") + * Transforms to Money instance + */ +export const zMoneyString = z.string().transform((val, ctx) => { + try { + return Money(val) + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Invalid money string: ${error instanceof Error ? error.message : "Unknown error"}`, + params: { centError: "PARSE_ERROR" }, + }) + return z.NEVER + } +}) + +/** + * Options for zMoney schema + */ +export interface ZMoneyOptions { + /** Restrict to specific currency */ + currency?: string + /** Minimum value (inclusive) */ + min?: string + /** Maximum value (inclusive) */ + max?: string + /** Require positive value (> 0) */ + positive?: boolean + /** Require non-negative value (>= 0) */ + nonNegative?: boolean + /** Require non-zero value (!= 0) */ + nonZero?: boolean +} + +/** + * Create a Money validation schema with optional constraints + * + * @example + * ```ts + * // Basic - any currency + * const schema = zMoney() + * schema.parse("$100.50") // Money instance + * + * // Currency constrained + * const usdSchema = zMoney("USD") + * usdSchema.parse("$100") // OK + * usdSchema.parse("€100") // Error + * + * // With validation options + * const paymentSchema = zMoney({ + * currency: "USD", + * min: "$0.50", + * max: "$10000", + * positive: true, + * }) + * ``` + */ +export function zMoney(currencyOrOptions?: string | ZMoneyOptions) { + const options: ZMoneyOptions = + typeof currencyOrOptions === "string" + ? { currency: currencyOrOptions } + : (currencyOrOptions ?? {}) + + // Parse min/max as Money if provided + let minMoney: InstanceType | undefined + let maxMoney: InstanceType | undefined + + if (options.min) { + try { + minMoney = Money(options.min) + } catch { + throw new Error(`Invalid min value: ${options.min}`) + } + } + + if (options.max) { + try { + maxMoney = Money(options.max) + } catch { + throw new Error(`Invalid max value: ${options.max}`) + } + } + + // Get expected currency if specified + let expectedCurrency: Currency | undefined + if (options.currency) { + expectedCurrency = getCurrencyFromCode(options.currency) + } + + return z + .union([zMoneyString, zMoneyJSON, z.instanceof(MoneyClass)]) + .transform((money, ctx) => { + // Handle passthrough of Money instances + const result = money instanceof MoneyClass ? money : money + + // Currency validation + if (expectedCurrency && result.currency.code !== expectedCurrency.code) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Expected currency ${expectedCurrency.code}, got ${result.currency.code}`, + }) + return z.NEVER + } + + // Min validation + if (minMoney && result.compare(minMoney) < 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Amount must be at least ${minMoney.toString()}`, + }) + return z.NEVER + } + + // Max validation + if (maxMoney && result.compare(maxMoney) > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Amount must be at most ${maxMoney.toString()}`, + }) + return z.NEVER + } + + // Positive validation + if (options.positive && !result.isPositive()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: "Amount must be positive", + }) + return z.NEVER + } + + // Non-negative validation + if (options.nonNegative && result.isNegative()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: "Amount must be non-negative", + }) + return z.NEVER + } + + // Non-zero validation + if (options.nonZero && result.isZero()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: "Amount must not be zero", + }) + return z.NEVER + } + + return result + }) +} diff --git a/packages/cent-zod/src/schemas/price-range.ts b/packages/cent-zod/src/schemas/price-range.ts new file mode 100644 index 0000000..ae49887 --- /dev/null +++ b/packages/cent-zod/src/schemas/price-range.ts @@ -0,0 +1,193 @@ +import { + Money, + MoneyClass, + PriceRangeClass, + PriceRangeFactory, +} from "@thesis-co/cent" +import { z } from "zod" + +/** + * Schema for PriceRange from string (e.g., "$50 - $100") + */ +export const zPriceRangeString = z.string().transform((val, ctx) => { + try { + return PriceRangeFactory(val) + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Invalid price range string: ${error instanceof Error ? error.message : "Unknown error"}`, + }) + return z.NEVER + } +}) + +/** + * Schema for PriceRange from min/max object + */ +export const zPriceRangeObject = z + .object({ + min: z.union([z.string(), z.instanceof(MoneyClass)]), + max: z.union([z.string(), z.instanceof(MoneyClass)]), + }) + .transform((data, ctx) => { + try { + const min = typeof data.min === "string" ? Money(data.min) : data.min + const max = typeof data.max === "string" ? Money(data.max) : data.max + return new PriceRangeClass(min, max) + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Invalid price range: ${error instanceof Error ? error.message : "Unknown error"}`, + }) + return z.NEVER + } + }) + +/** + * Schema for PriceRange from JSON (with nested Money JSON) + */ +export const zPriceRangeJSON = z + .object({ + min: z.unknown(), + max: z.unknown(), + }) + .transform((data, ctx) => { + try { + return PriceRangeClass.fromJSON(data) + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Invalid price range JSON: ${error instanceof Error ? error.message : "Unknown error"}`, + }) + return z.NEVER + } + }) + +/** + * Options for zPriceRange schema + */ +export interface ZPriceRangeOptions { + /** Expected currency */ + currency?: string + /** Minimum allowed span between min and max */ + minSpan?: string + /** Maximum allowed span between min and max */ + maxSpan?: string + /** Bounds constraints */ + bounds?: { + /** Minimum allowed value for the range's min */ + min?: string + /** Maximum allowed value for the range's max */ + max?: string + } +} + +/** + * Create a PriceRange validation schema with optional constraints + * + * @example + * ```ts + * // Basic - any currency + * const schema = zPriceRange() + * schema.parse("$50 - $100") // PriceRange instance + * + * // Currency constrained + * const usdSchema = zPriceRange("USD") + * + * // With span and bounds constraints + * const filterSchema = zPriceRange({ + * currency: "USD", + * bounds: { min: "$0", max: "$10000" }, + * minSpan: "$10", + * }) + * ``` + */ +export function zPriceRange(currencyOrOptions?: string | ZPriceRangeOptions) { + const options: ZPriceRangeOptions = + typeof currencyOrOptions === "string" + ? { currency: currencyOrOptions } + : (currencyOrOptions ?? {}) + + const baseSchema = z.union([ + zPriceRangeString, + zPriceRangeObject, + zPriceRangeJSON, + ]) + + if ( + !options.currency && + !options.minSpan && + !options.maxSpan && + !options.bounds + ) { + return baseSchema + } + + // Parse constraint values + let minSpanMoney: InstanceType | undefined + let maxSpanMoney: InstanceType | undefined + let boundsMin: InstanceType | undefined + let boundsMax: InstanceType | undefined + + if (options.minSpan) { + minSpanMoney = Money(options.minSpan) + } + if (options.maxSpan) { + maxSpanMoney = Money(options.maxSpan) + } + if (options.bounds?.min) { + boundsMin = Money(options.bounds.min) + } + if (options.bounds?.max) { + boundsMax = Money(options.bounds.max) + } + + return baseSchema.transform((range, ctx) => { + // Currency validation + if (options.currency && range.currency.code !== options.currency) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Expected currency ${options.currency}, got ${range.currency.code}`, + }) + return z.NEVER + } + + // Min span validation + if (minSpanMoney && range.span.compare(minSpanMoney) < 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Range span must be at least ${minSpanMoney.toString()}`, + }) + return z.NEVER + } + + // Max span validation + if (maxSpanMoney && range.span.compare(maxSpanMoney) > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Range span must be at most ${maxSpanMoney.toString()}`, + }) + return z.NEVER + } + + // Bounds min validation + if (boundsMin && range.min.compare(boundsMin) < 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Range minimum must be at least ${boundsMin.toString()}`, + }) + return z.NEVER + } + + // Bounds max validation + if (boundsMax && range.max.compare(boundsMax) > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Range maximum must be at most ${boundsMax.toString()}`, + }) + return z.NEVER + } + + return range + }) +} diff --git a/packages/cent-zod/src/schemas/price.ts b/packages/cent-zod/src/schemas/price.ts new file mode 100644 index 0000000..d9b235a --- /dev/null +++ b/packages/cent-zod/src/schemas/price.ts @@ -0,0 +1,118 @@ +import { Money, MoneyClass, Price } from "@thesis-co/cent" +import { z } from "zod" + +/** + * Schema for Price from an object with two amounts + */ +export const zPriceFromObject = z + .object({ + numerator: z.union([z.string(), z.instanceof(MoneyClass)]), + denominator: z.union([z.string(), z.instanceof(MoneyClass)]), + time: z.string().optional(), + }) + .transform((data, ctx) => { + try { + const numerator = + typeof data.numerator === "string" + ? Money(data.numerator) + : data.numerator + const denominator = + typeof data.denominator === "string" + ? Money(data.denominator) + : data.denominator + return new Price(numerator, denominator, data.time) + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Invalid price: ${error instanceof Error ? error.message : "Unknown error"}`, + }) + return z.NEVER + } + }) + +/** + * Schema for Price from a tuple of two money strings + * e.g., ["$50,000", "1 BTC"] + */ +export const zPriceFromTuple = z + .tuple([z.string(), z.string()]) + .transform((data, ctx) => { + try { + const [str1, str2] = data + const money1 = Money(str1) + const money2 = Money(str2) + return new Price(money1, money2) + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Invalid price: ${error instanceof Error ? error.message : "Unknown error"}`, + }) + return z.NEVER + } + }) + +/** + * Options for zPrice schema + */ +export interface ZPriceOptions { + /** Expected currency for numerator */ + numeratorCurrency?: string + /** Expected currency for denominator */ + denominatorCurrency?: string +} + +/** + * Create a Price validation schema with optional currency constraints + * + * @example + * ```ts + * // Basic - any currencies + * const schema = zPrice() + * schema.parse({ numerator: "$50,000", denominator: "1 BTC" }) + * + * // With currency constraints + * const btcUsdSchema = zPrice("USD", "BTC") + * btcUsdSchema.parse(["$50,000", "1 BTC"]) // OK + * ``` + */ +export function zPrice( + numeratorCurrency?: string, + denominatorCurrency?: string, +) { + const baseSchema = z.union([zPriceFromObject, zPriceFromTuple]) + + if (!numeratorCurrency && !denominatorCurrency) { + return baseSchema + } + + return baseSchema.transform((price, ctx) => { + const numCurrency = price.amounts[0].asset + const denomCurrency = price.amounts[1].asset + + if ( + numeratorCurrency && + "code" in numCurrency && + numCurrency.code !== numeratorCurrency + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Expected numerator currency ${numeratorCurrency}, got ${numCurrency.code}`, + }) + return z.NEVER + } + + if ( + denominatorCurrency && + "code" in denomCurrency && + denomCurrency.code !== denominatorCurrency + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Expected denominator currency ${denominatorCurrency}, got ${denomCurrency.code}`, + }) + return z.NEVER + } + + return price + }) +} diff --git a/packages/cent-zod/test/currency.test.ts b/packages/cent-zod/test/currency.test.ts new file mode 100644 index 0000000..4197bbe --- /dev/null +++ b/packages/cent-zod/test/currency.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "@jest/globals" +import { getValidCurrencyCodes, zCurrency, zCurrencyCode } from "../src" + +describe("zCurrencyCode", () => { + it("transforms valid currency code to Currency object", () => { + const result = zCurrencyCode.parse("USD") + expect(result.code).toBe("USD") + expect(result.symbol).toBe("$") + }) + + it("handles lowercase input", () => { + const result = zCurrencyCode.parse("usd") + expect(result.code).toBe("USD") + }) + + it("rejects unknown currency codes", () => { + expect(() => zCurrencyCode.parse("XXX")).toThrow(/Unknown currency/) + }) +}) + +describe("zCurrency", () => { + it("accepts any valid currency by default", () => { + const schema = zCurrency() + expect(schema.parse("USD").code).toBe("USD") + expect(schema.parse("EUR").code).toBe("EUR") + expect(schema.parse("BTC").code).toBe("BTC") + }) + + describe("with allowed list", () => { + it("accepts currencies in allowed list", () => { + const schema = zCurrency({ allowed: ["USD", "EUR"] }) + expect(schema.parse("USD").code).toBe("USD") + expect(schema.parse("EUR").code).toBe("EUR") + }) + + it("rejects currencies not in allowed list", () => { + const schema = zCurrency({ allowed: ["USD", "EUR"] }) + expect(() => schema.parse("GBP")).toThrow(/not in allowed list/) + }) + }) + + describe("with denied list", () => { + it("rejects currencies in denied list", () => { + const schema = zCurrency({ denied: ["BTC"] }) + expect(() => schema.parse("BTC")).toThrow(/not allowed/) + }) + + it("accepts currencies not in denied list", () => { + const schema = zCurrency({ denied: ["BTC"] }) + expect(schema.parse("USD").code).toBe("USD") + }) + }) + + describe("with type filter", () => { + it("fiat filter accepts fiat currencies", () => { + const schema = zCurrency({ type: "fiat" }) + expect(schema.parse("USD").code).toBe("USD") + expect(schema.parse("EUR").code).toBe("EUR") + }) + + it("fiat filter rejects crypto currencies", () => { + const schema = zCurrency({ type: "fiat" }) + expect(() => schema.parse("BTC")).toThrow(/not a fiat currency/) + }) + + it("crypto filter accepts crypto currencies", () => { + const schema = zCurrency({ type: "crypto" }) + expect(schema.parse("BTC").code).toBe("BTC") + expect(schema.parse("ETH").code).toBe("ETH") + }) + + it("crypto filter rejects fiat currencies", () => { + const schema = zCurrency({ type: "crypto" }) + expect(() => schema.parse("USD")).toThrow(/not a cryptocurrency/) + }) + }) +}) + +describe("getValidCurrencyCodes", () => { + it("returns array of currency codes", () => { + const codes = getValidCurrencyCodes() + expect(codes).toContain("USD") + expect(codes).toContain("EUR") + expect(codes).toContain("BTC") + expect(codes.length).toBeGreaterThan(100) + }) +}) diff --git a/packages/cent-zod/test/exchange-rate.test.ts b/packages/cent-zod/test/exchange-rate.test.ts new file mode 100644 index 0000000..87b2894 --- /dev/null +++ b/packages/cent-zod/test/exchange-rate.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "@jest/globals" +import { ExchangeRate } from "@thesis-co/cent" +import { zExchangeRate, zExchangeRateCompact, zExchangeRateJSON } from "../src" + +describe("zExchangeRateCompact", () => { + it("parses compact exchange rate format", () => { + const result = zExchangeRateCompact.parse({ + base: "USD", + quote: "EUR", + rate: "0.92", + }) + expect(result).toBeInstanceOf(ExchangeRate) + expect(result.baseCurrency.code).toBe("USD") + expect(result.quoteCurrency.code).toBe("EUR") + }) + + it("accepts optional timestamp", () => { + const result = zExchangeRateCompact.parse({ + base: "USD", + quote: "EUR", + rate: "0.92", + timestamp: "1704067200", + }) + expect(result).toBeInstanceOf(ExchangeRate) + }) +}) + +describe("zExchangeRateJSON", () => { + it("parses full JSON format with currency codes", () => { + const result = zExchangeRateJSON.parse({ + baseCurrency: "USD", + quoteCurrency: "EUR", + rate: "0.92", + }) + expect(result).toBeInstanceOf(ExchangeRate) + }) + + it("parses JSON format with fixed point rate", () => { + const result = zExchangeRateJSON.parse({ + baseCurrency: "USD", + quoteCurrency: "EUR", + rate: { amount: "92", decimals: "2" }, + }) + expect(result).toBeInstanceOf(ExchangeRate) + }) +}) + +describe("zExchangeRate", () => { + it("accepts compact format", () => { + const schema = zExchangeRate() + const result = schema.parse({ + base: "USD", + quote: "EUR", + rate: "0.92", + }) + expect(result).toBeInstanceOf(ExchangeRate) + }) + + it("accepts JSON format", () => { + const schema = zExchangeRate() + const result = schema.parse({ + baseCurrency: "USD", + quoteCurrency: "EUR", + rate: "0.92", + }) + expect(result).toBeInstanceOf(ExchangeRate) + }) + + describe("with currency constraints", () => { + it("accepts matching currency pair", () => { + const schema = zExchangeRate("USD", "EUR") + const result = schema.parse({ + base: "USD", + quote: "EUR", + rate: "0.92", + }) + expect(result.baseCurrency.code).toBe("USD") + expect(result.quoteCurrency.code).toBe("EUR") + }) + + it("rejects non-matching base currency", () => { + const schema = zExchangeRate("USD", "EUR") + expect(() => + schema.parse({ + base: "GBP", + quote: "EUR", + rate: "0.92", + }), + ).toThrow(/Expected base currency USD/) + }) + + it("rejects non-matching quote currency", () => { + const schema = zExchangeRate("USD", "EUR") + expect(() => + schema.parse({ + base: "USD", + quote: "GBP", + rate: "0.92", + }), + ).toThrow(/Expected quote currency EUR/) + }) + }) + + describe("with options object", () => { + it("accepts options with base and quote", () => { + const schema = zExchangeRate({ base: "BTC", quote: "USD" }) + const result = schema.parse({ + base: "BTC", + quote: "USD", + rate: "50000", + }) + expect(result.baseCurrency.code).toBe("BTC") + }) + }) +}) diff --git a/packages/cent-zod/test/money.test.ts b/packages/cent-zod/test/money.test.ts new file mode 100644 index 0000000..117acc0 --- /dev/null +++ b/packages/cent-zod/test/money.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "@jest/globals" +import { MoneyClass } from "@thesis-co/cent" +import { zMoney, zMoneyJSON, zMoneyString } from "../src" + +describe("zMoneyString", () => { + it("parses money string with symbol prefix", () => { + const result = zMoneyString.parse("$100.50") + expect(result).toBeInstanceOf(MoneyClass) + expect(result.toString()).toBe("$100.50") + }) + + it("parses money string with currency code suffix", () => { + const result = zMoneyString.parse("100.50 USD") + expect(result).toBeInstanceOf(MoneyClass) + expect(result.currency.code).toBe("USD") + }) + + it("parses euro amounts", () => { + const result = zMoneyString.parse("€50.00") + expect(result.currency.code).toBe("EUR") + }) + + it("rejects invalid money strings", () => { + expect(() => zMoneyString.parse("invalid")).toThrow() + }) +}) + +describe("zMoneyJSON", () => { + it("parses Money JSON with currency code string", () => { + const result = zMoneyJSON.parse({ + currency: "USD", + amount: "100.50", + }) + expect(result).toBeInstanceOf(MoneyClass) + expect(result.toString()).toBe("$100.50") + }) + + it("parses Money JSON with full currency object", () => { + const result = zMoneyJSON.parse({ + currency: { + name: "United States dollar", + code: "USD", + decimals: "2", + symbol: "$", + }, + amount: "50.00", + }) + expect(result).toBeInstanceOf(MoneyClass) + // Custom currency objects preserve their properties + expect(result.currency.name).toBe("United States dollar") + }) + + it("parses Money JSON with rational amount", () => { + const result = zMoneyJSON.parse({ + currency: "USD", + amount: { p: "10050", q: "100" }, + }) + expect(result).toBeInstanceOf(MoneyClass) + }) +}) + +describe("zMoney", () => { + it("accepts string format", () => { + const schema = zMoney() + const result = schema.parse("$100.50") + expect(result).toBeInstanceOf(MoneyClass) + }) + + it("accepts JSON format", () => { + const schema = zMoney() + const result = schema.parse({ + currency: "USD", + amount: "100.50", + }) + expect(result).toBeInstanceOf(MoneyClass) + }) + + it("accepts Money instance passthrough", () => { + const schema = zMoney() + const money = MoneyClass.fromJSON({ + currency: "USD", + amount: "100.50", + }) + const result = schema.parse(money) + expect(result).toBe(money) + }) + + describe("with currency constraint", () => { + it("accepts matching currency", () => { + const schema = zMoney("USD") + const result = schema.parse("$100.00") + expect(result.currency.code).toBe("USD") + }) + + it("rejects non-matching currency", () => { + const schema = zMoney("USD") + expect(() => schema.parse("€100.00")).toThrow(/Expected currency USD/) + }) + }) + + describe("with min/max constraints", () => { + it("accepts value within range", () => { + const schema = zMoney({ min: "$10.00", max: "$100.00" }) + const result = schema.parse("$50.00") + expect(result.toString()).toBe("$50.00") + }) + + it("rejects value below min", () => { + const schema = zMoney({ min: "$10.00" }) + expect(() => schema.parse("$5.00")).toThrow(/at least/) + }) + + it("rejects value above max", () => { + const schema = zMoney({ max: "$100.00" }) + expect(() => schema.parse("$150.00")).toThrow(/at most/) + }) + }) + + describe("with positive/nonNegative/nonZero constraints", () => { + it("positive rejects zero", () => { + const schema = zMoney({ positive: true }) + expect(() => schema.parse("$0.00")).toThrow(/positive/) + }) + + it("positive rejects negative", () => { + const schema = zMoney({ positive: true }) + expect(() => schema.parse("-$10.00")).toThrow(/positive/) + }) + + it("nonNegative accepts zero", () => { + const schema = zMoney({ nonNegative: true }) + const result = schema.parse("$0.00") + expect(result.isZero()).toBe(true) + }) + + it("nonNegative rejects negative", () => { + const schema = zMoney({ nonNegative: true }) + expect(() => schema.parse("-$10.00")).toThrow(/non-negative/) + }) + + it("nonZero rejects zero", () => { + const schema = zMoney({ nonZero: true }) + expect(() => schema.parse("$0.00")).toThrow(/not be zero/) + }) + }) +}) diff --git a/packages/cent-zod/test/price-range.test.ts b/packages/cent-zod/test/price-range.test.ts new file mode 100644 index 0000000..5189020 --- /dev/null +++ b/packages/cent-zod/test/price-range.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "@jest/globals" +import { PriceRangeClass } from "@thesis-co/cent" +import { zPriceRange, zPriceRangeObject, zPriceRangeString } from "../src" + +describe("zPriceRangeString", () => { + it("parses price range string with dash separator", () => { + const result = zPriceRangeString.parse("$50 - $100") + expect(result).toBeInstanceOf(PriceRangeClass) + expect(result.min.toString()).toBe("$50.00") + expect(result.max.toString()).toBe("$100.00") + }) + + it("parses price range string without spaces", () => { + const result = zPriceRangeString.parse("$50-$100") + expect(result).toBeInstanceOf(PriceRangeClass) + }) + + it("rejects invalid range strings", () => { + expect(() => zPriceRangeString.parse("invalid")).toThrow() + }) +}) + +describe("zPriceRangeObject", () => { + it("parses object with min and max strings", () => { + const result = zPriceRangeObject.parse({ + min: "$50.00", + max: "$100.00", + }) + expect(result).toBeInstanceOf(PriceRangeClass) + expect(result.min.toString()).toBe("$50.00") + expect(result.max.toString()).toBe("$100.00") + }) +}) + +describe("zPriceRange", () => { + it("accepts string format", () => { + const schema = zPriceRange() + const result = schema.parse("$50 - $100") + expect(result).toBeInstanceOf(PriceRangeClass) + }) + + it("accepts object format", () => { + const schema = zPriceRange() + const result = schema.parse({ + min: "$50.00", + max: "$100.00", + }) + expect(result).toBeInstanceOf(PriceRangeClass) + }) + + describe("with currency constraint", () => { + it("accepts matching currency", () => { + const schema = zPriceRange("USD") + const result = schema.parse("$50 - $100") + expect(result.currency.code).toBe("USD") + }) + + it("rejects non-matching currency", () => { + const schema = zPriceRange("USD") + expect(() => schema.parse("€50 - €100")).toThrow(/Expected currency USD/) + }) + }) + + describe("with span constraints", () => { + it("accepts range with sufficient span", () => { + const schema = zPriceRange({ minSpan: "$10.00" }) + const result = schema.parse("$50 - $100") + expect(result.span.toString()).toBe("$50.00") + }) + + it("rejects range with insufficient span", () => { + const schema = zPriceRange({ minSpan: "$100.00" }) + expect(() => schema.parse("$50 - $60")).toThrow(/span must be at least/) + }) + + it("rejects range with excessive span", () => { + const schema = zPriceRange({ maxSpan: "$20.00" }) + expect(() => schema.parse("$50 - $100")).toThrow(/span must be at most/) + }) + }) + + describe("with bounds constraints", () => { + it("accepts range within bounds", () => { + const schema = zPriceRange({ + bounds: { min: "$0.00", max: "$1000.00" }, + }) + const result = schema.parse("$50 - $100") + expect(result).toBeInstanceOf(PriceRangeClass) + }) + + it("rejects range below bounds min", () => { + const schema = zPriceRange({ + bounds: { min: "$100.00" }, + }) + expect(() => schema.parse("$50 - $200")).toThrow( + /Range minimum must be at least/, + ) + }) + + it("rejects range above bounds max", () => { + const schema = zPriceRange({ + bounds: { max: "$100.00" }, + }) + expect(() => schema.parse("$50 - $200")).toThrow( + /Range maximum must be at most/, + ) + }) + }) +}) diff --git a/packages/cent-zod/test/price.test.ts b/packages/cent-zod/test/price.test.ts new file mode 100644 index 0000000..18e5455 --- /dev/null +++ b/packages/cent-zod/test/price.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "@jest/globals" +import { Price } from "@thesis-co/cent" +import { zPrice, zPriceFromObject, zPriceFromTuple } from "../src" + +describe("zPriceFromTuple", () => { + it("parses tuple of money strings", () => { + const result = zPriceFromTuple.parse(["$50000", "1 BTC"]) + expect(result).toBeInstanceOf(Price) + expect(result.amounts[0].asset.code).toBe("USD") + expect(result.amounts[1].asset.code).toBe("BTC") + }) +}) + +describe("zPriceFromObject", () => { + it("parses object with numerator and denominator strings", () => { + const result = zPriceFromObject.parse({ + numerator: "$50000", + denominator: "1 BTC", + }) + expect(result).toBeInstanceOf(Price) + }) + + it("parses object with optional time", () => { + const result = zPriceFromObject.parse({ + numerator: "$50000", + denominator: "1 BTC", + time: "1704067200", + }) + expect(result).toBeInstanceOf(Price) + }) +}) + +describe("zPrice", () => { + it("accepts tuple format", () => { + const schema = zPrice() + const result = schema.parse(["$50000", "1 BTC"]) + expect(result).toBeInstanceOf(Price) + }) + + it("accepts object format", () => { + const schema = zPrice() + const result = schema.parse({ + numerator: "$50000", + denominator: "1 BTC", + }) + expect(result).toBeInstanceOf(Price) + }) + + describe("with currency constraints", () => { + it("accepts matching currencies", () => { + const schema = zPrice("USD", "BTC") + const result = schema.parse(["$50000", "1 BTC"]) + expect(result.amounts[0].asset.code).toBe("USD") + expect(result.amounts[1].asset.code).toBe("BTC") + }) + + it("rejects non-matching numerator currency", () => { + const schema = zPrice("USD", "BTC") + expect(() => schema.parse(["€50000", "1 BTC"])).toThrow( + /Expected numerator currency USD/, + ) + }) + + it("rejects non-matching denominator currency", () => { + const schema = zPrice("USD", "BTC") + expect(() => schema.parse(["$50000", "1 ETH"])).toThrow( + /Expected denominator currency BTC/, + ) + }) + }) +}) diff --git a/packages/cent-zod/tsconfig.json b/packages/cent-zod/tsconfig.json new file mode 100644 index 0000000..8c0e217 --- /dev/null +++ b/packages/cent-zod/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "target": "ES2020", + "lib": ["ES2020", "DOM"] + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/cent/package.json b/packages/cent/package.json index ff403e4..f408b78 100644 --- a/packages/cent/package.json +++ b/packages/cent/package.json @@ -38,7 +38,7 @@ "ts-jest": "^29.1.2" }, "dependencies": { - "zod": "^3.25.67" + "zod": "^4.0.0" }, "browserslist": { "production": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3741b2f..fa655c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: packages/cent: dependencies: zod: - specifier: ^3.25.67 - version: 3.25.76 + specifier: ^4.0.0 + version: 4.3.5 devDependencies: '@types/jest': specifier: ^29.5.12 @@ -37,6 +37,27 @@ importers: specifier: ^29.1.2 version: 29.4.0(@babel/core@7.28.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.9))(typescript@5.5.4) + packages/cent-zod: + devDependencies: + '@thesis-co/cent': + specifier: workspace:* + version: link:../cent + '@types/jest': + specifier: ^29.5.12 + version: 29.5.14 + '@types/node': + specifier: ^20.11.24 + version: 20.19.9 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.19.9) + ts-jest: + specifier: ^29.1.2 + version: 29.4.0(@babel/core@7.28.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.9))(typescript@5.5.4) + zod: + specifier: ^4.0.0 + version: 4.3.5 + packages: '@ampproject/remapping@2.3.0': @@ -1279,8 +1300,8 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.3.5: + resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==} snapshots: @@ -2758,4 +2779,4 @@ snapshots: yocto-queue@0.1.0: {} - zod@3.25.76: {} + zod@4.3.5: {}