import { z } from "zod"; import { ResultAsync, okAsync, errAsync } from "neverthrow"; import { logger } from "~/services/logger.server"; import type { VercelApiError } from "./vercelIntegration.server"; // --------------------------------------------------------------------------- // Recovery utilities for Vercel SDK validation errors // --------------------------------------------------------------------------- // // The Vercel SDK (Speakeasy-generated) validates API responses with strict Zod // schemas. When the API returns valid data but a field doesn't match the SDK's // type (e.g., `deletedAt: null` vs `number`), a `ResponseValidationError` is // thrown — even though the response contains all the data we need. // // Error hierarchy: // VercelError.body → raw HTTP body text (HTTP errors — never recover) // ResponseValidationError.rawValue → parsed JSON that failed validation // SDKValidationError.rawValue → same pattern, different base class // // Recovery: gate on validation error type → extract rawValue → validate → return. // --------------------------------------------------------------------------- /** * Only attempt recovery for SDK validation errors — not HTTP errors (401/403). * * ResponseValidationError and SDKValidationError both carry `rawValue` with the * parsed JSON that failed schema validation. VercelError (HTTP errors) carries * `body` instead — we must NOT recover from those since the response is an error * payload, not the data we asked for. */ function isValidationError(error: unknown): boolean { if (!error || typeof error !== "object") return false; if (!(error instanceof Error)) return false; return ( error.constructor.name === "ResponseValidationError" || error.constructor.name === "SDKValidationError" || "rawValue" in error ); } function extractRawValue(error: unknown): unknown | undefined { if (!error || typeof error !== "object") return undefined; if ("rawValue" in error) { return (error as { rawValue: unknown }).rawValue; } return undefined; } /** * Attempt to recover usable data from a Vercel SDK error. * * Returns the validated data on success, or `undefined` if recovery fails. */ export function recoverFromVercelSdkError( error: unknown, schema: z.ZodType, options?: { context?: string } ): T | undefined { if (!isValidationError(error)) return undefined; const raw = extractRawValue(error); if (raw === undefined) return undefined; const result = schema.safeParse(raw); if (!result.success) return undefined; logger.warn("Recovered data from Vercel SDK validation error", { context: options?.context, errorMessage: error instanceof Error ? error.message : String(error), errorType: error?.constructor?.name, }); return result.data; } /** * Wrap a Vercel SDK promise with automatic recovery on validation errors. * * On success: returns the SDK result as-is. * On error: attempts recovery via rawValue + schema validation (validation errors only). */ export function callVercelWithRecovery( sdkCall: Promise, schema: z.ZodType, options?: { context?: string } ): ResultAsync { return ResultAsync.fromPromise(sdkCall, (error) => error).orElse((error) => { const recovered = recoverFromVercelSdkError(error, schema, options); if (recovered !== undefined) { return okAsync(recovered); } return errAsync(error); }); } /** * Drop-in replacement for `wrapVercelCall` with SDK error recovery. * * Wraps a Vercel SDK promise in ResultAsync with structured error logging, * attempting to recover from validation errors before treating as failure. */ export function wrapVercelCallWithRecovery( promise: Promise, schema: z.ZodType, message: string, context: Record, toError: (error: unknown) => VercelApiError ): ResultAsync { return callVercelWithRecovery(promise, schema, { context: message }).mapErr((error) => { const apiError = toError(error); logger.error(message, { ...context, error, authInvalid: apiError.authInvalid }); return apiError; }); } // --------------------------------------------------------------------------- // Minimal Zod schemas — validate only the fields we actually use. // All use .passthrough() to preserve extra fields from the API response. // --------------------------------------------------------------------------- export const VercelSchemas = { getTeam: z.object({ slug: z.string() }).passthrough(), getAuthUser: z.object({ user: z.object({ username: z.string() }).passthrough() }).passthrough(), getCustomEnvironments: z .object({ environments: z .array( z .object({ id: z.string(), slug: z.string(), description: z.string().optional(), branchMatcher: z.unknown().optional(), }) .passthrough() ) .optional(), }) .passthrough(), filterProjectEnvs: z .union([ z .object({ envs: z.array(z.record(z.unknown())), pagination: z.unknown().optional(), }) .passthrough(), z.array(z.record(z.unknown())), ]) .transform((val) => (Array.isArray(val) ? { envs: val } : val)), getProjectEnv: z.object({ key: z.string(), value: z.string().optional() }).passthrough(), getProjects: z.union([ z.array(z.object({ id: z.string(), name: z.string() }).passthrough()), z .object({ projects: z.array(z.object({ id: z.string(), name: z.string() }).passthrough()), pagination: z.unknown().optional(), }) .passthrough(), ]), listSharedEnvVariable: z .object({ data: z .array( z .object({ id: z.string().optional(), key: z.string().optional(), type: z.string().optional(), target: z.unknown().optional(), value: z.string().optional(), }) .passthrough() ) .optional(), }) .passthrough(), getSharedEnvVar: z.object({ value: z.string().optional() }).passthrough(), updateProject: z .object({ id: z.string(), name: z.string(), autoAssignCustomDomains: z.boolean().optional() }) .passthrough(), } as const;