From 9cf50bedb68936d5dda093f4d7b703ccee3a375c Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:34:59 -0700 Subject: [PATCH 01/13] Upgrade to Zod 4.5 and compile schemas Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- .changeset/zod-45-compiled-schemas.md | 13 + packages/world-local/src/queue.test.ts | 2 +- packages/world-local/src/queue.ts | 16 +- .../world-local/src/storage/events-storage.ts | 24 +- packages/world-local/src/storage/helpers.ts | 22 +- .../world-local/src/storage/hook-index.ts | 18 +- packages/world-local/src/streamer.ts | 8 +- packages/world-postgres/src/message.ts | 28 +- packages/world-postgres/src/queue.ts | 16 +- packages/world-postgres/src/streamer.ts | 10 +- packages/world-postgres/src/zod.ts | 2 +- packages/world-vercel/src/encryption.ts | 5 +- packages/world-vercel/src/events-v4.ts | 82 +++-- packages/world-vercel/src/frames.ts | 2 +- packages/world-vercel/src/hooks.ts | 12 +- packages/world-vercel/src/queue.ts | 22 +- .../src/resolve-latest-deployment.ts | 8 +- packages/world-vercel/src/runs.ts | 34 +- packages/world-vercel/src/steps.ts | 36 ++- packages/world-vercel/src/streamer.ts | 34 +- packages/world/src/analytics.ts | 186 ++++++----- packages/world/src/attributes.ts | 8 +- packages/world/src/events.ts | 80 ++--- packages/world/src/hooks.ts | 60 ++-- packages/world/src/queue.ts | 227 ++++++------- packages/world/src/runs.ts | 115 +++---- packages/world/src/schema-compilation.test.ts | 59 ++++ packages/world/src/serialization.ts | 16 +- packages/world/src/shared.ts | 12 +- packages/world/src/steps.ts | 104 +++--- packages/world/src/waits.ts | 22 +- pnpm-lock.yaml | 303 ++++++++---------- pnpm-workspace.yaml | 2 +- .../workflows/100_durable_agent_e2e.ts | 2 +- workbench/example/workflows/4_ai.ts | 2 +- .../nextjs-turbopack/workflows/agent_chat.ts | 2 +- 36 files changed, 869 insertions(+), 725 deletions(-) create mode 100644 .changeset/zod-45-compiled-schemas.md create mode 100644 packages/world/src/schema-compilation.test.ts diff --git a/.changeset/zod-45-compiled-schemas.md b/.changeset/zod-45-compiled-schemas.md new file mode 100644 index 0000000000..11afa980e4 --- /dev/null +++ b/.changeset/zod-45-compiled-schemas.md @@ -0,0 +1,13 @@ +--- +'@workflow/ai': patch +'@workflow/cli': patch +'@workflow/core': patch +'@workflow/world': patch +'@workflow/world-local': patch +'@workflow/world-postgres': patch +'@workflow/world-testing': patch +'@workflow/world-vercel': patch +'workflow': patch +--- + +Upgrade runtime validation to Zod 4.5 and precompile hot-path World schemas. diff --git a/packages/world-local/src/queue.test.ts b/packages/world-local/src/queue.test.ts index a243eaca77..f932fa106a 100644 --- a/packages/world-local/src/queue.test.ts +++ b/packages/world-local/src/queue.test.ts @@ -4,7 +4,7 @@ import { setWorkflowBasePath } from '@workflow/utils'; import type { WorkflowInvokePayload } from '@workflow/world'; import { MessageId, NODE_HTTP_ENV_VAR, ValidQueueName } from '@workflow/world'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { z } from 'zod/v4'; +import { z } from 'zod'; import { createQueue, DEFAULT_BODY_TIMEOUT_MS, diff --git a/packages/world-local/src/queue.ts b/packages/world-local/src/queue.ts index be460bd7b1..3a221742af 100644 --- a/packages/world-local/src/queue.ts +++ b/packages/world-local/src/queue.ts @@ -17,7 +17,7 @@ import { import { Sema } from 'async-sema'; import { monotonicFactory } from 'ulid'; import { Agent } from 'undici'; -import { z } from 'zod/v4'; +import { z } from 'zod'; import type { Config } from './config.js'; import { resolveBaseUrl, resolveDirectBaseUrl } from './config.js'; import { jsonReplacer, jsonReviver } from './fs.js'; @@ -63,6 +63,14 @@ const MAX_SAFE_TIMEOUT_MS = 2147483647; // The local workers share the same Node.js process and event loop, // so we need to limit concurrency to avoid overwhelming the system. const DEFAULT_CONCURRENCY_LIMIT = 1000; + +const HeaderParser = z.compile( + z.object({ + 'x-vqs-queue-name': ValidQueueName, + 'x-vqs-message-id': MessageId, + 'x-vqs-message-attempt': z.coerce.number(), + }) +); const WORKFLOW_LOCAL_QUEUE_CONCURRENCY = parseInt(process.env.WORKFLOW_LOCAL_QUEUE_CONCURRENCY ?? '0', 10) || DEFAULT_CONCURRENCY_LIMIT; @@ -397,12 +405,6 @@ export function createQueue(config: Partial): LocalQueue { return { messageId }; }; - const HeaderParser = z.object({ - 'x-vqs-queue-name': ValidQueueName, - 'x-vqs-message-id': MessageId, - 'x-vqs-message-attempt': z.coerce.number(), - }); - const createQueueHandler: Queue['createQueueHandler'] = (prefix, handler) => { return async (req) => { const headers = HeaderParser.safeParse(Object.fromEntries(req.headers)); diff --git a/packages/world-local/src/storage/events-storage.ts b/packages/world-local/src/storage/events-storage.ts index 721f149103..015207e75e 100644 --- a/packages/world-local/src/storage/events-storage.ts +++ b/packages/world-local/src/storage/events-storage.ts @@ -164,9 +164,11 @@ function getHookRetentionLimitMs(): number { * lifetimes can never share one marker (see * `hookRecoveryMarkerPath`). */ -const HookRecoveryMarkerSchema = z.object({ - eventId: z.string(), -}); +const HookRecoveryMarkerSchema = z.compile( + z.object({ + eventId: z.string(), + }) +); /** * Durable `(runId, resumeId)` claim for a lazy hook resume. Written via @@ -177,13 +179,15 @@ const HookRecoveryMarkerSchema = z.object({ * records the content hash so a reused `resumeId` carrying a different payload * can be rejected as a conflict, matching the server's constraint. */ -const HookResumeClaimSchema = z.object({ - runId: z.string(), - resumeId: z.string(), - hookId: z.string(), - eventId: z.string(), - payloadDigest: z.string().optional(), -}); +const HookResumeClaimSchema = z.compile( + z.object({ + runId: z.string(), + resumeId: z.string(), + hookId: z.string(), + eventId: z.string(), + payloadDigest: z.string().optional(), + }) +); /** * Whether `event` is the `hook_received` a resume claim stands for. diff --git a/packages/world-local/src/storage/helpers.ts b/packages/world-local/src/storage/helpers.ts index a4fb238636..9584fec930 100644 --- a/packages/world-local/src/storage/helpers.ts +++ b/packages/world-local/src/storage/helpers.ts @@ -348,16 +348,18 @@ export function hookTokenClaimPath(basedir: string, token: string): string { return path.join(basedir, 'hooks', 'tokens', `${hashToken(token)}.json`); } -export const HookTokenClaimSchema = z.object({ - // Legacy claims omitted hookId. Keeping it optional preserves their - // existing cross-hook conflict behavior (see #2283). - hookId: z.string().optional(), - runId: z.string(), - // Legacy claims also omitted eventId. Their recovery marker pins the - // canonical hook_created event before concurrent retries publish it. - eventId: z.string().optional(), - tokenRetentionUntil: z.coerce.date().optional(), -}); +export const HookTokenClaimSchema = z.compile( + z.object({ + // Legacy claims omitted hookId. Keeping it optional preserves their + // existing cross-hook conflict behavior (see #2283). + hookId: z.string().optional(), + runId: z.string(), + // Legacy claims also omitted eventId. Their recovery marker pins the + // canonical hook_created event before concurrent retries publish it. + eventId: z.string().optional(), + tokenRetentionUntil: z.coerce.date().optional(), + }) +); export type HookTokenClaim = z.infer; diff --git a/packages/world-local/src/storage/hook-index.ts b/packages/world-local/src/storage/hook-index.ts index b22f23d120..421e0e2fb3 100644 --- a/packages/world-local/src/storage/hook-index.ts +++ b/packages/world-local/src/storage/hook-index.ts @@ -39,14 +39,18 @@ import { hashToken } from './helpers.js'; * (`ensureHookIndexes`) guarded by a completion marker. */ -const IndexEntrySchema = z.object({ - runId: z.string(), -}); +const IndexEntrySchema = z.compile( + z.object({ + runId: z.string(), + }) +); -const ByRunMarkerSchema = z.object({ - hookId: z.string(), - tag: z.string().optional(), -}); +const ByRunMarkerSchema = z.compile( + z.object({ + hookId: z.string(), + tag: z.string().optional(), + }) +); // No `.json` extension so entity listings never pick it up. const INDEX_COMPLETE_MARKER = '.hook-index-complete'; diff --git a/packages/world-local/src/streamer.ts b/packages/world-local/src/streamer.ts index a4379de59a..195a4b08e3 100644 --- a/packages/world-local/src/streamer.ts +++ b/packages/world-local/src/streamer.ts @@ -32,9 +32,11 @@ const chunkIds = globalSingleton( const monotonicUlid = (seedTime?: number): string => chunkIds.next(seedTime); // Schema for the run-to-streams mapping file -const RunStreamsSchema = z.object({ - streams: z.array(z.string()), -}); +const RunStreamsSchema = z.compile( + z.object({ + streams: z.array(z.string()), + }) +); /** * A chunk consists of a boolean `eof` indicating if it's the last chunk, diff --git a/packages/world-postgres/src/message.ts b/packages/world-postgres/src/message.ts index c6d5729e19..f672e34dd5 100644 --- a/packages/world-postgres/src/message.ts +++ b/packages/world-postgres/src/message.ts @@ -1,5 +1,5 @@ import { MessageId } from '@workflow/world'; -import { z } from 'zod/v4'; +import { z } from 'zod'; import { Base64Buffer } from './zod.js'; /** @@ -7,16 +7,18 @@ import { Base64Buffer } from './zod.js'; * the body to ensure binary safety * maybe later we can have a `blobs` table for larger payloads */ -export const MessageData = z.object({ - attempt: z.number().describe('The attempt number of the message'), - messageId: MessageId.describe('The unique ID of the message'), - idempotencyKey: z.string().optional(), - headers: z.record(z.string(), z.string()).optional(), - id: z - .string() - .describe( - "The ID of the sub-queue. For workflows, it's the workflow name. For steps, it's the step name." - ), - data: Base64Buffer.describe('The message that was sent'), -}); +export const MessageData = z.compile( + z.object({ + attempt: z.number().describe('The attempt number of the message'), + messageId: MessageId.describe('The unique ID of the message'), + idempotencyKey: z.string().optional(), + headers: z.record(z.string(), z.string()).optional(), + id: z + .string() + .describe( + "The ID of the sub-queue. For workflows, it's the workflow name. For steps, it's the step name." + ), + data: Base64Buffer.describe('The message that was sent'), + }) +); export type MessageData = z.infer; diff --git a/packages/world-postgres/src/queue.ts b/packages/world-postgres/src/queue.ts index 38655120e4..0c77f3da6d 100644 --- a/packages/world-postgres/src/queue.ts +++ b/packages/world-postgres/src/queue.ts @@ -29,7 +29,7 @@ import { } from 'graphile-worker'; import type { Pool } from 'pg'; import { monotonicFactory } from 'ulid'; -import { z } from 'zod/v4'; +import { z } from 'zod'; import type { PostgresWorldConfig } from './config.js'; import { MessageData } from './message.js'; @@ -55,12 +55,14 @@ const graphileLogger = createGraphileLogger(); const COMPLETED_IDEMPOTENCY_CACHE_LIMIT = 10_000; // Core records MAX_DELIVERIES_EXCEEDED on delivery 49. const MAX_GRAPHILE_JOB_ATTEMPTS = 49; -const GraphileHelpers = z.object({ - abortSignal: z.instanceof(AbortSignal).optional(), - job: z.object({ - attempts: z.number().int().positive(), - }), -}); +const GraphileHelpers = z.compile( + z.object({ + abortSignal: z.instanceof(AbortSignal).optional(), + job: z.object({ + attempts: z.number().int().positive(), + }), + }) +); type HttpExecutionResult = | { type: 'completed' } diff --git a/packages/world-postgres/src/streamer.ts b/packages/world-postgres/src/streamer.ts index a79c3ebd75..faadb72b65 100644 --- a/packages/world-postgres/src/streamer.ts +++ b/packages/world-postgres/src/streamer.ts @@ -12,10 +12,12 @@ import * as z from 'zod'; import { type Drizzle, Schema } from './drizzle/index.js'; import { Mutex } from './util.js'; -const StreamPublishMessage = z.object({ - streamId: z.string(), - chunkId: z.templateLiteral(['chnk_', z.string()]), -}); +const StreamPublishMessage = z.compile( + z.object({ + streamId: z.string(), + chunkId: z.templateLiteral(['chnk_', z.string()]), + }) +); interface StreamChunkEvent { id: `chnk_${string}`; diff --git a/packages/world-postgres/src/zod.ts b/packages/world-postgres/src/zod.ts index e420eafcce..cd52342ef1 100644 --- a/packages/world-postgres/src/zod.ts +++ b/packages/world-postgres/src/zod.ts @@ -1,4 +1,4 @@ -import { z } from 'zod/v4'; +import { z } from 'zod'; export const Base64Buffer = z.codec(z.base64(), z.instanceof(Buffer), { decode(b64) { diff --git a/packages/world-vercel/src/encryption.ts b/packages/world-vercel/src/encryption.ts index 63ececad91..c12170481b 100644 --- a/packages/world-vercel/src/encryption.ts +++ b/packages/world-vercel/src/encryption.ts @@ -17,6 +17,9 @@ import { getDispatcher } from './http-client.js'; import { instrumentedFetch, resolveVercelApiToken } from './http-core.js'; const KEY_BYTES = 32; // 256 bits = 32 bytes (AES-256) +const RunKeyResponseSchema = z.compile( + z.object({ key: z.string().nullable() }) +); class RunKeyFetchError extends Error { readonly status: number; @@ -150,7 +153,7 @@ export async function fetchRunKey( }); const data = await response.json(); - const result = z.object({ key: z.string().nullable() }).safeParse(data); + const result = RunKeyResponseSchema.safeParse(data); if (!result.success) { throw new Error( `Invalid response from Vercel API: expected { key: string | null }. Zod error: ${result.error.message}` diff --git a/packages/world-vercel/src/events-v4.ts b/packages/world-vercel/src/events-v4.ts index 6ffdbc51da..cc325e82e8 100644 --- a/packages/world-vercel/src/events-v4.ts +++ b/packages/world-vercel/src/events-v4.ts @@ -322,34 +322,42 @@ const CreateEventV4PageSchema = z.union([ cursor: z.string().nullable(), hasMore: z.boolean(), }), + // A response without a page omits these keys outright. Since Zod 4.4, + // `z.undefined()` no longer makes an object key implicitly optional. z.object({ - events: z.undefined(), - cursor: z.undefined(), - hasMore: z.undefined(), + events: z.undefined().optional(), + cursor: z.undefined().optional(), + hasMore: z.undefined().optional(), }), ]); -const CreateEventV4BodySchema = CreateEventV4BodyBaseSchema.and( - CreateEventV4PageSchema +const CreateEventV4BodySchema = z.compile( + CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema) ); const CreateEventV4BodySchemas: { [T in EventType]: z.ZodType & { event: Event }>; } = { - run_created: CreateEventV4BodyBaseSchema.extend({ - run: WorkflowRunSchema, - }).and(CreateEventV4PageSchema), - run_started: CreateEventV4BodyBaseSchema.extend({ - run: WorkflowRunSchema.and(z.object({ startedAt: z.coerce.date() })), - }).and(CreateEventV4PageSchema), - step_started: CreateEventV4BodyBaseSchema.extend({ - step: StepWireSchema.extend({ - startedAt: z.coerce.date(), - }).transform((step) => ({ - ...deserializeStep(step), - startedAt: step.startedAt, - })), - }).and(CreateEventV4PageSchema), + run_created: z.compile( + CreateEventV4BodyBaseSchema.extend({ + run: WorkflowRunSchema, + }).and(CreateEventV4PageSchema) + ), + run_started: z.compile( + CreateEventV4BodyBaseSchema.extend({ + run: WorkflowRunSchema.and(z.object({ startedAt: z.coerce.date() })), + }).and(CreateEventV4PageSchema) + ), + step_started: z.compile( + CreateEventV4BodyBaseSchema.extend({ + step: StepWireSchema.extend({ + startedAt: z.coerce.date(), + }).transform((step) => ({ + ...deserializeStep(step), + startedAt: step.startedAt, + })), + }).and(CreateEventV4PageSchema) + ), run_completed: CreateEventV4BodySchema, run_failed: CreateEventV4BodySchema, run_cancelled: CreateEventV4BodySchema, @@ -370,22 +378,26 @@ const CreateEventV4BodySchemas: { }; const MaxEventsHeaderSchema = z.coerce.number().int().positive(); -const EventStreamEndSchema = z.object({ - _end: z.literal(1), - next: z.string().optional(), - hasMore: z.boolean(), -}); +const EventStreamEndSchema = z.compile( + z.object({ + _end: z.literal(1), + next: z.string().optional(), + hasMore: z.boolean(), + }) +); /** * Terminal error frame. The backend sends this when it cannot finish a frame * stream and retrying will not help — the response already committed to `200` * with its first byte, so there is no status code left to carry the failure. */ -const EventStreamErrorSchema = z.object({ - _error: z.literal(1), - code: z.string(), - message: z.string().optional(), -}); +const EventStreamErrorSchema = z.compile( + z.object({ + _error: z.literal(1), + code: z.string(), + message: z.string().optional(), + }) +); /** * An event's payload object is gone from the backend's blob storage. The @@ -894,11 +906,13 @@ export interface CreateEventBatchV4Result { results: CreateEventBatchV4ItemResult[]; } -const BatchItemFailureSchema = z.object({ - status: z.number().int(), - error: z.string(), - message: z.string(), -}); +const BatchItemFailureSchema = z.compile( + z.object({ + status: z.number().int(), + error: z.string(), + message: z.string(), + }) +); /** * POST /api/v4/runs/:runId/events/batch diff --git a/packages/world-vercel/src/frames.ts b/packages/world-vercel/src/frames.ts index 93809ab179..70bf6dd0df 100644 --- a/packages/world-vercel/src/frames.ts +++ b/packages/world-vercel/src/frames.ts @@ -20,7 +20,7 @@ export interface DecodedFrame { // The protocol consumer validates the event or control-frame shape after the // body is available. The byte codec only requires a CBOR object here. -const CborObjectSchema = z.record(z.string(), z.unknown()); +const CborObjectSchema = z.compile(z.record(z.string(), z.unknown())); /** Test/utility: encode a complete frame. Production server uses prefix * + streaming body. */ diff --git a/packages/world-vercel/src/hooks.ts b/packages/world-vercel/src/hooks.ts index e3add16a2d..37cad7fa88 100644 --- a/packages/world-vercel/src/hooks.ts +++ b/packages/world-vercel/src/hooks.ts @@ -20,11 +20,13 @@ function filterHookData(hook: any, resolveData: 'none' | 'all'): Hook { } return normalizeHookData(hook) as Hook; } -const HookWithRefsSchema = HookSchema.omit({ - metadata: true, -}).extend({ - metadataRef: z.any().optional(), -}); +const HookWithRefsSchema = z.compile( + HookSchema.omit({ + metadata: true, + }).extend({ + metadataRef: z.any().optional(), + }) +); export async function listHooks( params: ListHooksParams, diff --git a/packages/world-vercel/src/queue.ts b/packages/world-vercel/src/queue.ts index 0f1eec291e..55dca2d7e7 100644 --- a/packages/world-vercel/src/queue.ts +++ b/packages/world-vercel/src/queue.ts @@ -13,7 +13,7 @@ import { ValidQueueName, } from '@workflow/world'; import { decode as cborDecode, encode as cborEncode } from 'cbor-x'; -import { z } from 'zod/v4'; +import { z } from 'zod'; import { missingDeploymentIdMessage } from './deployment-id.js'; import { getQueueDispatcher } from './http-client.js'; import { decode as decodeTaggedRunId } from './run-id/index.js'; @@ -105,15 +105,17 @@ class DualTransport implements Transport { // same module copy, so the context never has to cross a copy boundary. const requestIdStorage = new AsyncLocalStorage(); -const MessageWrapper = z.object({ - payload: QueuePayloadSchema, - queueName: ValidQueueName, - /** - * The deployment ID to use when re-enqueueing the message. - * This ensures the message is processed by the same deployment. - */ - deploymentId: z.string().optional(), -}); +const MessageWrapper = z.compile( + z.object({ + payload: QueuePayloadSchema, + queueName: ValidQueueName, + /** + * The deployment ID to use when re-enqueueing the message. + * This ensures the message is processed by the same deployment. + */ + deploymentId: z.string().optional(), + }) +); /** * Sleep Implementation via Message Delays diff --git a/packages/world-vercel/src/resolve-latest-deployment.ts b/packages/world-vercel/src/resolve-latest-deployment.ts index 0d32e99e09..d68b899266 100644 --- a/packages/world-vercel/src/resolve-latest-deployment.ts +++ b/packages/world-vercel/src/resolve-latest-deployment.ts @@ -13,9 +13,11 @@ import { getDispatcher } from './http-client.js'; import { instrumentedFetch, resolveVercelApiToken } from './http-core.js'; import type { APIConfig } from './utils.js'; -const ResolveLatestDeploymentResponseSchema = z.object({ - id: z.string(), -}); +const ResolveLatestDeploymentResponseSchema = z.compile( + z.object({ + id: z.string(), + }) +); /** * Resolve the credential this call should authenticate with. diff --git a/packages/world-vercel/src/runs.ts b/packages/world-vercel/src/runs.ts index 28df1297cf..bfbb841594 100644 --- a/packages/world-vercel/src/runs.ts +++ b/packages/world-vercel/src/runs.ts @@ -53,21 +53,23 @@ export const WorkflowRunWireBaseSchema = WorkflowRunBaseSchema.omit({ }); // Wire schema for resolved data (full input/output) -const WorkflowRunWireSchema = WorkflowRunWireBaseSchema; +const WorkflowRunWireSchema = z.compile(WorkflowRunWireBaseSchema); // Wire schema for lazy mode with refs instead of data // input/output can be Uint8Array (v2) or any JSON (legacy v1) -const WorkflowRunWireWithRefsSchema = WorkflowRunWireBaseSchema.omit({ - input: true, - output: true, -}).extend({ - // We discard the results of the refs, so we don't care about the type here - inputRef: z.any().optional(), - outputRef: z.any().optional(), - // Accept both Uint8Array (v2 format) and any (legacy v1 JSON format) - input: z.union([z.instanceof(Uint8Array), z.any()]).optional(), - output: z.union([z.instanceof(Uint8Array), z.any()]).optional(), -}); +const WorkflowRunWireWithRefsSchema = z.compile( + WorkflowRunWireBaseSchema.omit({ + input: true, + output: true, + }).extend({ + // We discard the results of the refs, so we don't care about the type here + inputRef: z.any().optional(), + outputRef: z.any().optional(), + // Accept both Uint8Array (v2 format) and any (legacy v1 JSON format) + input: z.union([z.instanceof(Uint8Array), z.any()]).optional(), + output: z.union([z.instanceof(Uint8Array), z.any()]).optional(), + }) +); // Overloaded function signatures for filterRunData function filterRunData(run: any, resolveData: 'none'): WorkflowRunWithoutData; @@ -566,9 +568,11 @@ export async function cancelWorkflowRuns( * returns the post-merge attribute snapshot so callers don't need to * issue a follow-up read. */ -const ExperimentalSetAttributesResponseSchema = z.object({ - attributes: z.record(z.string(), z.string()), -}); +const ExperimentalSetAttributesResponseSchema = z.compile( + z.object({ + attributes: z.record(z.string(), z.string()), + }) +); /** * Apply attribute changes to a workflow run. The body shape mirrors the diff --git a/packages/world-vercel/src/steps.ts b/packages/world-vercel/src/steps.ts index 09a3c2dd6a..cd395ab64e 100644 --- a/packages/world-vercel/src/steps.ts +++ b/packages/world-vercel/src/steps.ts @@ -26,24 +26,28 @@ import { * For backward compatibility with legacy wire formats, we also accept * any other shape and let the resolved `errorRef` supersede it when present. */ -export const StepWireSchema = StepSchema.omit({ - error: true, -}).extend({ - error: z.union([SerializedDataSchema, z.any()]).optional(), - errorRef: z.any().optional(), -}); +export const StepWireSchema = z.compile( + StepSchema.omit({ + error: true, + }).extend({ + error: z.union([SerializedDataSchema, z.any()]).optional(), + errorRef: z.any().optional(), + }) +); // Wire schema for lazy mode with refs instead of data -const StepWireWithRefsSchema = StepWireSchema.omit({ - input: true, - output: true, -}).extend({ - // We discard the results of the refs, so we don't care about the type here - inputRef: z.any().optional(), - outputRef: z.any().optional(), - input: z.instanceof(Uint8Array).optional(), - output: z.instanceof(Uint8Array).optional(), -}); +const StepWireWithRefsSchema = z.compile( + StepWireSchema.omit({ + input: true, + output: true, + }).extend({ + // We discard the results of the refs, so we don't care about the type here + inputRef: z.any().optional(), + outputRef: z.any().optional(), + input: z.instanceof(Uint8Array).optional(), + output: z.instanceof(Uint8Array).optional(), + }) +); /** * Transform step from wire format to Step interface format. diff --git a/packages/world-vercel/src/streamer.ts b/packages/world-vercel/src/streamer.ts index 55a368c760..f7e07f8409 100644 --- a/packages/world-vercel/src/streamer.ts +++ b/packages/world-vercel/src/streamer.ts @@ -179,27 +179,31 @@ export function encodeMultiChunks(chunks: (string | Uint8Array)[]): Uint8Array { return result; } -const StreamInfoResponseSchema = z.object({ - tailIndex: z.number(), - done: z.boolean(), -}); +const StreamInfoResponseSchema = z.compile( + z.object({ + tailIndex: z.number(), + done: z.boolean(), + }) +); /** * Zod schema for the paginated stream chunks response from the server. * When using CBOR (the default for makeRequest), chunk data arrives as * native Uint8Array byte strings, so no base64 decoding is required. */ -const StreamChunksResponseSchema = z.object({ - data: z.array( - z.object({ - index: z.number(), - data: z.instanceof(Uint8Array), - }) - ), - cursor: z.string().nullable(), - hasMore: z.boolean(), - done: z.boolean(), -}); +const StreamChunksResponseSchema = z.compile( + z.object({ + data: z.array( + z.object({ + index: z.number(), + data: z.instanceof(Uint8Array), + }) + ), + cursor: z.string().nullable(), + hasMore: z.boolean(), + done: z.boolean(), + }) +); /** Creates the HTTP-backed streamer that talks to workflow-server. */ export function createStreamer(config?: APIConfig): Streamer { diff --git a/packages/world/src/analytics.ts b/packages/world/src/analytics.ts index 90753010c5..13eeabef59 100644 --- a/packages/world/src/analytics.ts +++ b/packages/world/src/analytics.ts @@ -39,98 +39,110 @@ const NullableBooleanSchema = z.boolean().nullable().optional(); // Keep analytics object schemas standalone even when they mirror storage // metadata fields. This namespace is an explicit metadata-only read contract; // payload and secret fields should only appear here through deliberate opt-in. -export const AnalyticsRunSchema = z.object({ - runId: z.string(), - status: WorkflowRunStatusSchema, - deploymentId: z.string(), - workflowName: z.string(), - specVersion: z.coerce.number().optional(), - attributes: z.record(z.string(), z.string()).default({}), - createdAt: UTCDateSchema, - updatedAt: UTCDateSchema, - startedAt: NullableDateSchema, - completedAt: NullableDateSchema, - errorCode: NullableStringSchema, - workflowCoreVersion: NullableStringSchema, - workflowEncryptionEnabled: NullableBooleanSchema, -}); +export const AnalyticsRunSchema = z.compile( + z.object({ + runId: z.string(), + status: WorkflowRunStatusSchema, + deploymentId: z.string(), + workflowName: z.string(), + specVersion: z.coerce.number().optional(), + attributes: z.record(z.string(), z.string()).default({}), + createdAt: UTCDateSchema, + updatedAt: UTCDateSchema, + startedAt: NullableDateSchema, + completedAt: NullableDateSchema, + errorCode: NullableStringSchema, + workflowCoreVersion: NullableStringSchema, + workflowEncryptionEnabled: NullableBooleanSchema, + }) +); -export const AnalyticsStepSchema = z.object({ - runId: z.string(), - stepId: z.string(), - stepName: NullableStringSchema, - status: StepStatusSchema, - attempt: z.number().optional(), - createdAt: UTCDateSchema, - updatedAt: UTCDateSchema, - startedAt: NullableDateSchema, - completedAt: NullableDateSchema, - retryAfter: NullableDateSchema, - errorCode: NullableStringSchema, - workflowCoreVersion: NullableStringSchema, - workflowEncryptionEnabled: NullableBooleanSchema, - /** Compute instance of the latest attempt's `step_started`. */ - computeInstanceId: NullableStringSchema, -}); +export const AnalyticsStepSchema = z.compile( + z.object({ + runId: z.string(), + stepId: z.string(), + stepName: NullableStringSchema, + status: StepStatusSchema, + attempt: z.number().optional(), + createdAt: UTCDateSchema, + updatedAt: UTCDateSchema, + startedAt: NullableDateSchema, + completedAt: NullableDateSchema, + retryAfter: NullableDateSchema, + errorCode: NullableStringSchema, + workflowCoreVersion: NullableStringSchema, + workflowEncryptionEnabled: NullableBooleanSchema, + /** Compute instance of the latest attempt's `step_started`. */ + computeInstanceId: NullableStringSchema, + }) +); -export const AnalyticsEventSchema = z.object({ - runId: z.string(), - eventId: z.string(), - eventType: EventTypeSchema, - correlationId: NullableStringSchema, - entityId: NullableStringSchema, - stepName: NullableStringSchema, - workflowName: z.string(), - deploymentId: z.string(), - specVersion: z.coerce.number().optional(), - runCreatedAt: UTCDateSchema, - createdAt: UTCDateSchema, - region: NullableStringSchema, - vercelId: NullableStringSchema, - requestId: NullableStringSchema, - /** Compute instance that wrote the event. See CreateEventParams. */ - computeInstanceId: NullableStringSchema, - resumeAt: NullableDateSchema, - retryAfter: NullableDateSchema, - errorCode: NullableStringSchema, - workflowCoreVersion: NullableStringSchema, - isWebhook: NullableBooleanSchema, - isSystem: NullableBooleanSchema, - workflowEncryptionEnabled: NullableBooleanSchema, -}); +export const AnalyticsEventSchema = z.compile( + z.object({ + runId: z.string(), + eventId: z.string(), + eventType: EventTypeSchema, + correlationId: NullableStringSchema, + entityId: NullableStringSchema, + stepName: NullableStringSchema, + workflowName: z.string(), + deploymentId: z.string(), + specVersion: z.coerce.number().optional(), + runCreatedAt: UTCDateSchema, + createdAt: UTCDateSchema, + region: NullableStringSchema, + vercelId: NullableStringSchema, + requestId: NullableStringSchema, + /** Compute instance that wrote the event. See CreateEventParams. */ + computeInstanceId: NullableStringSchema, + resumeAt: NullableDateSchema, + retryAfter: NullableDateSchema, + errorCode: NullableStringSchema, + workflowCoreVersion: NullableStringSchema, + isWebhook: NullableBooleanSchema, + isSystem: NullableBooleanSchema, + workflowEncryptionEnabled: NullableBooleanSchema, + }) +); -export const AnalyticsHookSchema = z.object({ - runId: z.string(), - hookId: z.string(), - status: z.enum(['created', 'received', 'disposed', 'conflict']), - createdAt: UTCDateSchema, - updatedAt: UTCDateSchema, - receivedAt: NullableDateSchema, - disposedAt: NullableDateSchema, - isWebhook: NullableBooleanSchema, - isSystem: NullableBooleanSchema, - workflowCoreVersion: NullableStringSchema, - workflowEncryptionEnabled: NullableBooleanSchema, -}); +export const AnalyticsHookSchema = z.compile( + z.object({ + runId: z.string(), + hookId: z.string(), + status: z.enum(['created', 'received', 'disposed', 'conflict']), + createdAt: UTCDateSchema, + updatedAt: UTCDateSchema, + receivedAt: NullableDateSchema, + disposedAt: NullableDateSchema, + isWebhook: NullableBooleanSchema, + isSystem: NullableBooleanSchema, + workflowCoreVersion: NullableStringSchema, + workflowEncryptionEnabled: NullableBooleanSchema, + }) +); -export const AnalyticsWaitSchema = z.object({ - runId: z.string(), - waitId: z.string(), - status: WaitStatusSchema, - resumeAt: NullableDateSchema, - createdAt: UTCDateSchema, - updatedAt: UTCDateSchema, - completedAt: NullableDateSchema, - workflowCoreVersion: NullableStringSchema, - workflowEncryptionEnabled: NullableBooleanSchema, -}); +export const AnalyticsWaitSchema = z.compile( + z.object({ + runId: z.string(), + waitId: z.string(), + status: WaitStatusSchema, + resumeAt: NullableDateSchema, + createdAt: UTCDateSchema, + updatedAt: UTCDateSchema, + completedAt: NullableDateSchema, + workflowCoreVersion: NullableStringSchema, + workflowEncryptionEnabled: NullableBooleanSchema, + }) +); -export const AnalyticsAttributeKeySchema = z.object({ - key: z.string(), - runCount: z.coerce.number(), - firstSeenAt: z.coerce.date(), - lastSeenAt: z.coerce.date(), -}); +export const AnalyticsAttributeKeySchema = z.compile( + z.object({ + key: z.string(), + runCount: z.coerce.number(), + firstSeenAt: z.coerce.date(), + lastSeenAt: z.coerce.date(), + }) +); export type AnalyticsRun = z.infer; export type AnalyticsStep = z.infer; diff --git a/packages/world/src/attributes.ts b/packages/world/src/attributes.ts index 177e870492..8a5ec26382 100644 --- a/packages/world/src/attributes.ts +++ b/packages/world/src/attributes.ts @@ -35,9 +35,8 @@ export const AttributeChangeSchema = z.object({ value: AttributeValueSchema, }) satisfies z.ZodType; -export const AttributeChangesSchema = z - .array(AttributeChangeSchema) - .superRefine((changes, context) => { +export const AttributeChangesSchema = z.compile( + z.array(AttributeChangeSchema).superRefine((changes, context) => { try { // Reserved keys are contextual: attr_set events may carry them when the // sibling allowReservedAttributes flag is set. Callers that prohibit the @@ -51,7 +50,8 @@ export const AttributeChangesSchema = z input: changes, }); } - }); + }) +); /** The post-merge attribute snapshot returned by a World. */ export interface ExperimentalSetAttributesResult { diff --git a/packages/world/src/events.ts b/packages/world/src/events.ts index d3fdbd95bb..bd5bc194f8 100644 --- a/packages/world/src/events.ts +++ b/packages/world/src/events.ts @@ -569,28 +569,30 @@ const RunCancelledEventSchema = BaseEventSchema.extend({ // Discriminated union for user-creatable events (requests to world.events.create) // Note: hook_conflict is NOT included here - it can only be created by World implementations -export const CreateEventSchema = z.discriminatedUnion('eventType', [ - // Run lifecycle events - RunCreatedEventSchema, - RunStartedEventSchema, - RunCompletedEventSchema, - RunFailedEventSchema, - RunCancelledEventSchema, - AttrSetEventSchema, - // Step lifecycle events - StepCreatedEventSchema, - StepCompletedEventSchema, - StepFailedEventSchema, - StepRetryingEventSchema, - StepStartedEventSchema, - // Hook lifecycle events - HookCreatedEventSchema, - HookReceivedEventSchema, - HookDisposedEventSchema, - // Wait lifecycle events - WaitCreatedEventSchema, - WaitCompletedEventSchema, -]); +export const CreateEventSchema = z.compile( + z.discriminatedUnion('eventType', [ + // Run lifecycle events + RunCreatedEventSchema, + RunStartedEventSchema, + RunCompletedEventSchema, + RunFailedEventSchema, + RunCancelledEventSchema, + AttrSetEventSchema, + // Step lifecycle events + StepCreatedEventSchema, + StepCompletedEventSchema, + StepFailedEventSchema, + StepRetryingEventSchema, + StepStartedEventSchema, + // Hook lifecycle events + HookCreatedEventSchema, + HookReceivedEventSchema, + HookDisposedEventSchema, + // Wait lifecycle events + WaitCreatedEventSchema, + WaitCompletedEventSchema, + ]) +); // Discriminated union for ALL events (includes World-only events like hook_conflict) // This is used for reading events from the event log @@ -621,22 +623,24 @@ const AllEventsSchema = z.discriminatedUnion('eventType', [ // Server response includes runId, eventId, and createdAt // specVersion is optional in database for backward compatibility -export const EventSchema = AllEventsSchema.and( - z.object({ - runId: z.string(), - eventId: z.string(), - createdAt: z.coerce.date(), - occurredAt: z.coerce.date().optional(), - specVersion: z.number().optional(), - /** - * Lazy hook resume idempotency key, persisted on `hook_received` events so - * the queue consumer can detect that the producer's concurrent direct write - * already landed in the run_started preload and skip its own re-ensure. - * Mirrors {@link CreateEventParams.resumeId}; absent on all other events and - * on legacy (non-lazy) resumes. - */ - resumeId: z.string().optional(), - }) +export const EventSchema = z.compile( + AllEventsSchema.and( + z.object({ + runId: z.string(), + eventId: z.string(), + createdAt: z.coerce.date(), + occurredAt: z.coerce.date().optional(), + specVersion: z.number().optional(), + /** + * Lazy hook resume idempotency key, persisted on `hook_received` events so + * the queue consumer can detect that the producer's concurrent direct write + * already landed in the run_started preload and skip its own re-ensure. + * Mirrors {@link CreateEventParams.resumeId}; absent on all other events and + * on legacy (non-lazy) resumes. + */ + resumeId: z.string().optional(), + }) + ) ); // Inferred types diff --git a/packages/world/src/hooks.ts b/packages/world/src/hooks.ts index cad3364357..da6d054c21 100644 --- a/packages/world/src/hooks.ts +++ b/packages/world/src/hooks.ts @@ -92,35 +92,37 @@ export type HookResumeCapabilities = z.infer< * - specVersion 1: any (legacy JSON format) */ // Hook schemas -export const HookSchema = z.object({ - runId: z.string(), - hookId: z.string(), - token: z.string(), - ownerId: z.string(), - projectId: z.string(), - environment: z.string(), - metadata: SerializedDataSchema.optional(), - createdAt: z.coerce.date(), - // Optional in database for backward compatibility, defaults to 1 (legacy) when reading - specVersion: z.number().optional(), - isWebhook: z.boolean().optional(), - isSystem: z.boolean().optional(), - // Earliest time the token can become available after the owning run ends. - // An active run keeps the token beyond this deadline. - tokenRetentionUntil: z.coerce.date().optional(), - // Present when the server stored it (new hooks) or synthesized it from the - // run (old hooks). Absent only against an old server, where the resume path - // falls back to `runs.get`. - resumeContext: HookResumeContextSchema.optional(), - // Backend dedup capability, computed FRESH by the server on every by-token - // lookup: RESPONSE-ONLY and TRANSIENT. Never persisted on the hook entity - // and never part of `resumeContext`, so a server rollback or kill switch - // takes effect on the next lookup (the field stops appearing). - // `resumeHook()` gates its lazy path on this being present and - // current. Absent against an older/rolled-back server or when the kill switch - // is active. - resumeCapabilities: HookResumeCapabilitiesSchema.optional(), -}); +export const HookSchema = z.compile( + z.object({ + runId: z.string(), + hookId: z.string(), + token: z.string(), + ownerId: z.string(), + projectId: z.string(), + environment: z.string(), + metadata: SerializedDataSchema.optional(), + createdAt: z.coerce.date(), + // Optional in database for backward compatibility, defaults to 1 (legacy) when reading + specVersion: z.number().optional(), + isWebhook: z.boolean().optional(), + isSystem: z.boolean().optional(), + // Earliest time the token can become available after the owning run ends. + // An active run keeps the token beyond this deadline. + tokenRetentionUntil: z.coerce.date().optional(), + // Present when the server stored it (new hooks) or synthesized it from the + // run (old hooks). Absent only against an old server, where the resume path + // falls back to `runs.get`. + resumeContext: HookResumeContextSchema.optional(), + // Backend dedup capability, computed FRESH by the server on every by-token + // lookup: RESPONSE-ONLY and TRANSIENT. Never persisted on the hook entity + // and never part of `resumeContext`, so a server rollback or kill switch + // takes effect on the next lookup (the field stops appearing). + // `resumeHook()` gates its lazy path on this being present and + // current. Absent against an older/rolled-back server or when the kill switch + // is active. + resumeCapabilities: HookResumeCapabilitiesSchema.optional(), + }) +); /** * Represents a Hook. Hooks kept by minimum retention remain readable after diff --git a/packages/world/src/queue.ts b/packages/world/src/queue.ts index 912c19f065..9431071778 100644 --- a/packages/world/src/queue.ts +++ b/packages/world/src/queue.ts @@ -1,4 +1,4 @@ -import { z } from 'zod/v4'; +import { z } from 'zod'; export type QueueKind = 'workflow'; @@ -266,98 +266,100 @@ export const HookResumeTimingSchema = z.object({ }); export type HookResumeTiming = z.infer; -export const WorkflowInvokePayloadSchema = z.object({ - runId: z.string(), - traceCarrier: TraceCarrierSchema.optional(), - requestedAt: z.coerce.date().optional(), - /** Consecutive replay divergences in this recovery chain and latest position. */ - replayDivergence: z - .object({ - eventId: z.string(), - count: z.number().int().positive(), - }) - .optional(), - /** - * Re-invocations so far in this chain of stale-snapshot (precondition) - * rejections. Counted on the message because the in-process restart budget - * lives in the invocation closure and the queue's delivery count resets on - * every fresh enqueue, so without this a permanently fenced run would cycle - * forever instead of failing. - */ - preconditionReinvocations: z.number().int().positive().optional(), - /** Number of times this message has been re-enqueued due to server errors (5xx) */ - serverErrorRetryCount: z.number().int().optional(), - /** Number of times this message has been re-routed after a deployment mismatch */ - deploymentMismatchRetryCount: z.number().int().nonnegative().optional(), - /** - * The wait this message is the delayed continuation for, and which attempt - * in that wait's chain it is. - * - * Present only on wait-continuation messages. It exists so the invocation a - * continuation wakes can recognize itself as that continuation: if the wait - * is STILL pending when it replays — the continuation arrived before its - * deadline — then its own idempotency key is already spent, and re-enqueueing - * under the same key is silently dropped by the world's dedupe window. The - * attempt number is what makes the next key fresh, so an early delivery - * costs one extra hop instead of losing the wait's timer permanently. - * - * Counted on the message for the same reason as - * {@link WorkflowInvokePayloadSchema.shape.preconditionReinvocations}: the - * budget has to survive across invocations, and a fresh enqueue resets - * anything the queue tracks itself. Absent on the first continuation, so a - * producer that predates this field is indistinguishable from attempt 0 and - * a consumer that predates it simply ignores the field. - */ - /** - * `.catch(undefined)` for the reason `hookResumeTiming` has it: this field - * must never be able to fail the parse of the invocation payload. A - * malformed value would otherwise throw on every delivery of the message - * and burn the run's delivery budget. Degrading to `undefined` reads as - * "not a continuation", which costs at worst the pre-attempt behavior for - * that one wait rather than killing the run. - */ - waitContinuation: z - .object({ - correlationId: z.string(), - attempt: z.number().int().nonnegative(), - }) - .optional() - .catch(undefined), - /** Step ID for inline step execution in combined handler. If provided, the flow execution - * will jump directly to execute the step with the given ID before doing an event replay. */ - stepId: z.string().optional(), - /** Step name, sent alongside stepId to avoid loading the event log to resolve the name. */ - stepName: z.string().optional(), - /** Run creation data, only present on the first queue delivery from start() */ - runInput: RunInputSchema.optional(), - /** - * Lazy hook resume data, only present when `resumeHook()` takes the parallel - * fast path. A consumer that understands this field idempotently ensures the - * `hook_received` event exists (keyed by `resumeId`) before replaying. - */ - hookInput: HookResumeInputSchema.optional(), - /** - * Resilient step dispatch data, only present alongside `stepId` when the - * producer parallelized the `step_created` write with this queue publish. A - * consumer that understands this field idempotently ensures the - * `step_created` event exists (keyed by `stepId`) before executing the step. - */ - stepInput: StepDispatchInputSchema.optional(), - /** - * Hook-resume TTR timing. Present on both `resumeHook()` dispatch paths - * (unlike `hookInput`, which only rides the lazy path), and - * forwarded onto a dispatched step message when the resuming invocation - * hands the next durable step to another invocation. Purely observational. - * See {@link HookResumeTimingSchema}. - * - * `.catch(undefined)` because this field must never be able to fail the - * parse of the invocation payload: a malformed value (a NaN boundary, a - * shape change from a future producer) would otherwise throw on every - * delivery of that message and burn the run's delivery budget over a - * telemetry field. Anything unparseable degrades to "no measurement". - */ - hookResumeTiming: HookResumeTimingSchema.optional().catch(undefined), -}); +export const WorkflowInvokePayloadSchema = z.compile( + z.object({ + runId: z.string(), + traceCarrier: TraceCarrierSchema.optional(), + requestedAt: z.coerce.date().optional(), + /** Consecutive replay divergences in this recovery chain and latest position. */ + replayDivergence: z + .object({ + eventId: z.string(), + count: z.number().int().positive(), + }) + .optional(), + /** + * Re-invocations so far in this chain of stale-snapshot (precondition) + * rejections. Counted on the message because the in-process restart budget + * lives in the invocation closure and the queue's delivery count resets on + * every fresh enqueue, so without this a permanently fenced run would cycle + * forever instead of failing. + */ + preconditionReinvocations: z.number().int().positive().optional(), + /** Number of times this message has been re-enqueued due to server errors (5xx) */ + serverErrorRetryCount: z.number().int().optional(), + /** Number of times this message has been re-routed after a deployment mismatch */ + deploymentMismatchRetryCount: z.number().int().nonnegative().optional(), + /** + * The wait this message is the delayed continuation for, and which attempt + * in that wait's chain it is. + * + * Present only on wait-continuation messages. It exists so the invocation a + * continuation wakes can recognize itself as that continuation: if the wait + * is STILL pending when it replays — the continuation arrived before its + * deadline — then its own idempotency key is already spent, and re-enqueueing + * under the same key is silently dropped by the world's dedupe window. The + * attempt number is what makes the next key fresh, so an early delivery + * costs one extra hop instead of losing the wait's timer permanently. + * + * Counted on the message for the same reason as + * {@link WorkflowInvokePayloadSchema.shape.preconditionReinvocations}: the + * budget has to survive across invocations, and a fresh enqueue resets + * anything the queue tracks itself. Absent on the first continuation, so a + * producer that predates this field is indistinguishable from attempt 0 and + * a consumer that predates it simply ignores the field. + */ + /** + * `.catch(undefined)` for the reason `hookResumeTiming` has it: this field + * must never be able to fail the parse of the invocation payload. A + * malformed value would otherwise throw on every delivery of the message + * and burn the run's delivery budget. Degrading to `undefined` reads as + * "not a continuation", which costs at worst the pre-attempt behavior for + * that one wait rather than killing the run. + */ + waitContinuation: z + .object({ + correlationId: z.string(), + attempt: z.number().int().nonnegative(), + }) + .optional() + .catch(undefined), + /** Step ID for inline step execution in combined handler. If provided, the flow execution + * will jump directly to execute the step with the given ID before doing an event replay. */ + stepId: z.string().optional(), + /** Step name, sent alongside stepId to avoid loading the event log to resolve the name. */ + stepName: z.string().optional(), + /** Run creation data, only present on the first queue delivery from start() */ + runInput: RunInputSchema.optional(), + /** + * Lazy hook resume data, only present when `resumeHook()` takes the parallel + * fast path. A consumer that understands this field idempotently ensures the + * `hook_received` event exists (keyed by `resumeId`) before replaying. + */ + hookInput: HookResumeInputSchema.optional(), + /** + * Resilient step dispatch data, only present alongside `stepId` when the + * producer parallelized the `step_created` write with this queue publish. A + * consumer that understands this field idempotently ensures the + * `step_created` event exists (keyed by `stepId`) before executing the step. + */ + stepInput: StepDispatchInputSchema.optional(), + /** + * Hook-resume TTR timing. Present on both `resumeHook()` dispatch paths + * (unlike `hookInput`, which only rides the lazy path), and + * forwarded onto a dispatched step message when the resuming invocation + * hands the next durable step to another invocation. Purely observational. + * See {@link HookResumeTimingSchema}. + * + * `.catch(undefined)` because this field must never be able to fail the + * parse of the invocation payload: a malformed value (a NaN boundary, a + * shape change from a future producer) would otherwise throw on every + * delivery of that message and burn the run's delivery budget over a + * telemetry field. Anything unparseable degrades to "no measurement". + */ + hookResumeTiming: HookResumeTimingSchema.optional().catch(undefined), + }) +); export type WorkflowInvokePayload = z.infer; export type HealthCheckPayload = z.infer; @@ -366,21 +368,23 @@ export type HealthCheckPayload = z.infer; * Health check payload - used to verify that the queue pipeline * can deliver messages to the combined workflow endpoint. */ -export const HealthCheckPayloadSchema = z.object({ - __healthCheck: z.literal(true), - correlationId: z.string(), - /** - * The run id the caller is about to create, when the probe is being used to - * prepare a cross-deployment `start()`. - * - * The responder runs inside the target deployment, so it can derive that - * run's public key locally from its own key material and return it in the - * probe response. That lets the caller seal the workflow arguments without - * a separate key lookup. Absent for probes issued for other reasons (CLI - * health command, dashboard), in which case no key is returned. - */ - runId: z.string().optional(), -}); +export const HealthCheckPayloadSchema = z.compile( + z.object({ + __healthCheck: z.literal(true), + correlationId: z.string(), + /** + * The run id the caller is about to create, when the probe is being used to + * prepare a cross-deployment `start()`. + * + * The responder runs inside the target deployment, so it can derive that + * run's public key locally from its own key material and return it in the + * probe response. That lets the caller seal the workflow arguments without + * a separate key lookup. Absent for probes issued for other reasons (CLI + * health command, dashboard), in which case no key is returned. + */ + runId: z.string().optional(), + }) +); /** * Health check MUST come first. @@ -399,10 +403,9 @@ export const HealthCheckPayloadSchema = z.object({ * Ordering health check first is safe in the other direction: it requires * `__healthCheck: true`, which an invoke payload never carries. */ -export const QueuePayloadSchema = z.union([ - HealthCheckPayloadSchema, - WorkflowInvokePayloadSchema, -]); +export const QueuePayloadSchema = z.compile( + z.union([HealthCheckPayloadSchema, WorkflowInvokePayloadSchema]) +); export type QueuePayload = z.infer; export interface QueueOptions { diff --git a/packages/world/src/runs.ts b/packages/world/src/runs.ts index 3412e1939e..2d1465f211 100644 --- a/packages/world/src/runs.ts +++ b/packages/world/src/runs.ts @@ -128,36 +128,38 @@ export const WorkflowRunBaseSchema = z.object({ }); // Discriminated union based on status -export const WorkflowRunSchema = z.discriminatedUnion('status', [ - // Non-final states - WorkflowRunBaseSchema.extend({ - status: z.enum(['pending', 'running']), - output: z.undefined().optional(), - error: z.undefined().optional(), - completedAt: z.undefined().optional(), - }), - // Cancelled state - WorkflowRunBaseSchema.extend({ - status: z.literal('cancelled'), - output: z.undefined().optional(), - error: z.undefined().optional(), - completedAt: z.coerce.date(), - }), - // Completed state - output can be v1 or v2 format - WorkflowRunBaseSchema.extend({ - status: z.literal('completed'), - output: SerializedDataSchema, - error: z.undefined().optional(), - completedAt: z.coerce.date(), - }), - // Failed state - WorkflowRunBaseSchema.extend({ - status: z.literal('failed'), - output: z.undefined().optional(), - error: SerializedDataSchema, - completedAt: z.coerce.date(), - }), -]); +export const WorkflowRunSchema = z.compile( + z.discriminatedUnion('status', [ + // Non-final states + WorkflowRunBaseSchema.extend({ + status: z.enum(['pending', 'running']), + output: z.undefined().optional(), + error: z.undefined().optional(), + completedAt: z.undefined().optional(), + }), + // Cancelled state + WorkflowRunBaseSchema.extend({ + status: z.literal('cancelled'), + output: z.undefined().optional(), + error: z.undefined().optional(), + completedAt: z.coerce.date(), + }), + // Completed state - output can be v1 or v2 format + WorkflowRunBaseSchema.extend({ + status: z.literal('completed'), + output: SerializedDataSchema, + error: z.undefined().optional(), + completedAt: z.coerce.date(), + }), + // Failed state + WorkflowRunBaseSchema.extend({ + status: z.literal('failed'), + output: z.undefined().optional(), + error: SerializedDataSchema, + completedAt: z.coerce.date(), + }), + ]) +); // Inferred types export type WorkflowRun = z.infer; @@ -235,16 +237,18 @@ export const BULK_CANCEL_MAX_RUN_IDS = 500; * `cancelReason` (max 512 chars) is recorded on each run_cancelled event, * mirroring the single-run {@link CancelRunOptions.cancelReason}. */ -export const BulkCancelWorkflowRunsRequestSchema = z.object({ - runIds: z - .array(z.string()) - .min(1) - .max(BULK_CANCEL_MAX_RUN_IDS) - .refine((ids) => new Set(ids).size === ids.length, { - message: 'runIds must not contain duplicates', - }), - cancelReason: z.string().max(512).optional(), -}); +export const BulkCancelWorkflowRunsRequestSchema = z.compile( + z.object({ + runIds: z + .array(z.string()) + .min(1) + .max(BULK_CANCEL_MAX_RUN_IDS) + .refine((ids) => new Set(ids).size === ids.length, { + message: 'runIds must not contain duplicates', + }), + cancelReason: z.string().max(512).optional(), + }) +); export type BulkCancelWorkflowRunsRequest = z.infer< typeof BulkCancelWorkflowRunsRequestSchema >; @@ -260,9 +264,8 @@ export type BulkCancelWorkflowRunsRequest = z.infer< * - `failed`: cancellation failed; `code` is a machine-readable error code * and `retryable` indicates whether retrying may succeed. */ -export const BulkCancelWorkflowRunResultSchema = z.discriminatedUnion( - 'outcome', - [ +export const BulkCancelWorkflowRunResultSchema = z.compile( + z.discriminatedUnion('outcome', [ z.object({ runId: z.string(), outcome: z.literal('cancelled') }), z.object({ runId: z.string(), outcome: z.literal('already_cancelled') }), z.object({ @@ -277,7 +280,7 @@ export const BulkCancelWorkflowRunResultSchema = z.discriminatedUnion( code: z.string(), retryable: z.boolean(), }), - ] + ]) ); export type BulkCancelWorkflowRunResult = z.infer< typeof BulkCancelWorkflowRunResultSchema @@ -287,17 +290,19 @@ export type BulkCancelWorkflowRunResult = z.infer< * Aggregate result of a bulk cancellation. `results` preserves the order of * the requested `runIds`; `summary` counts each outcome. */ -export const BulkCancelWorkflowRunsResultSchema = z.object({ - summary: z.object({ - requested: z.number(), - cancelled: z.number(), - alreadyCancelled: z.number(), - notCancellable: z.number(), - notFound: z.number(), - failed: z.number(), - }), - results: z.array(BulkCancelWorkflowRunResultSchema), -}); +export const BulkCancelWorkflowRunsResultSchema = z.compile( + z.object({ + summary: z.object({ + requested: z.number(), + cancelled: z.number(), + alreadyCancelled: z.number(), + notCancellable: z.number(), + notFound: z.number(), + failed: z.number(), + }), + results: z.array(BulkCancelWorkflowRunResultSchema), + }) +); export type BulkCancelWorkflowRunsResult = z.infer< typeof BulkCancelWorkflowRunsResultSchema >; diff --git a/packages/world/src/schema-compilation.test.ts b/packages/world/src/schema-compilation.test.ts new file mode 100644 index 0000000000..c73f490bf8 --- /dev/null +++ b/packages/world/src/schema-compilation.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import type { z } from 'zod'; +import { + AnalyticsAttributeKeySchema, + AnalyticsEventSchema, + AnalyticsHookSchema, + AnalyticsRunSchema, + AnalyticsStepSchema, + AnalyticsWaitSchema, +} from './analytics.js'; +import { AttributeChangesSchema } from './attributes.js'; +import { CreateEventSchema, EventSchema } from './events.js'; +import { HookSchema } from './hooks.js'; +import { + HealthCheckPayloadSchema, + QueuePayloadSchema, + WorkflowInvokePayloadSchema, +} from './queue.js'; +import { + BulkCancelWorkflowRunResultSchema, + BulkCancelWorkflowRunsRequestSchema, + BulkCancelWorkflowRunsResultSchema, + WorkflowRunSchema, +} from './runs.js'; +import { StructuredErrorSchema } from './shared.js'; +import { StepSchema } from './steps.js'; +import { WaitSchema } from './waits.js'; + +// Keep compilation explicit and library-owned. Importing `zod/compile` would +// change schema construction globally in every application that loads Workflow. +const hotPathSchemas = { + AnalyticsAttributeKeySchema, + AnalyticsEventSchema, + AnalyticsHookSchema, + AnalyticsRunSchema, + AnalyticsStepSchema, + AnalyticsWaitSchema, + AttributeChangesSchema, + BulkCancelWorkflowRunResultSchema, + BulkCancelWorkflowRunsRequestSchema, + BulkCancelWorkflowRunsResultSchema, + CreateEventSchema, + EventSchema, + HealthCheckPayloadSchema, + HookSchema, + QueuePayloadSchema, + StepSchema, + StructuredErrorSchema, + WaitSchema, + WorkflowInvokePayloadSchema, + WorkflowRunSchema, +} satisfies Record; + +describe('schema compilation', () => { + it.each(Object.entries(hotPathSchemas))('precompiles $0', (_name, schema) => { + const compilerState = schema._zod.bag as { validator?: unknown }; + expect(compilerState.validator).toBeTypeOf('function'); + }); +}); diff --git a/packages/world/src/serialization.ts b/packages/world/src/serialization.ts index bbb1f014d0..c78010caec 100644 --- a/packages/world/src/serialization.ts +++ b/packages/world/src/serialization.ts @@ -25,8 +25,16 @@ export const LegacySerializedDataSchemaV1: z.ZodType = z.any(); /** * Union schema that accepts both v2+ (Uint8Array) and legacy (any) serialized data. * Use this for validation when data may come from either specVersion. + * + * Serialized payloads are frequently absent: they travel in a frame body rather + * than the metadata object, or the event simply carries none. The `.optional()` + * is what allows the key to be missing at parse time. Before Zod 4.4 the + * `z.any()` branch did that implicitly, while the inferred type stayed + * required; newer Zod versions make `any`/`unknown` keys required at parse time + * too. The cast keeps the inferred type as it has always been, so consumers + * still see these keys as present, and only the parse-time tolerance is + * restored. */ -export const SerializedDataSchema = z.union([ - BinarySerializedDataSchema, - LegacySerializedDataSchemaV1, -]); +export const SerializedDataSchema = z + .union([BinarySerializedDataSchema, LegacySerializedDataSchemaV1]) + .optional() as unknown as z.ZodType; diff --git a/packages/world/src/shared.ts b/packages/world/src/shared.ts index 9c3d6d0e7f..465e6a98bd 100644 --- a/packages/world/src/shared.ts +++ b/packages/world/src/shared.ts @@ -61,11 +61,13 @@ export type ResolveData = 'none' | 'all'; /** * A standard error schema shape for propogating errors from runs and steps */ -export const StructuredErrorSchema = z.object({ - message: z.string(), - stack: z.string().optional(), - code: z.string().optional(), // Populated with RunErrorCode values (USER_ERROR, RUNTIME_ERROR, etc.) for run_failed events -}); +export const StructuredErrorSchema = z.compile( + z.object({ + message: z.string(), + stack: z.string().optional(), + code: z.string().optional(), // Populated with RunErrorCode values (USER_ERROR, RUNTIME_ERROR, etc.) for run_failed events + }) +); export type StructuredError = z.infer; diff --git a/packages/world/src/steps.ts b/packages/world/src/steps.ts index 53e3d1b77a..ce3e995ad9 100644 --- a/packages/world/src/steps.ts +++ b/packages/world/src/steps.ts @@ -19,57 +19,59 @@ export const StepStatusSchema = z.enum([ * - specVersion 1: any (legacy JSON format) */ // TODO: implement a discriminated union here to match the run schema -export const StepSchema = z.object({ - runId: z.string(), - stepId: z.string(), - /** - * The machine-readable name of the step function. - * - * This field contains a structured identifier like `step//./src/workflows/order//processPayment` - * that encodes the step's module specifier and function name. - * - * Use `parseStepName()` from `@workflow/utils/parse-name` to extract: - * - `shortName`: User-friendly display name (e.g., `"processPayment"`) - * - `moduleSpecifier`: The module path or package (e.g., `"./src/workflows/order"`) - * - `functionName`: The full function path (e.g., `"processPayment"` or `"outer/nested"`) - * - * @example - * ```ts - * import { parseStepName } from "@workflow/utils/parse-name"; - * - * const parsed = parseStepName(step.stepName); - * // parsed.shortName → "processPayment" - * // parsed.moduleSpecifier → "./src/workflows/order" - * ``` - */ - stepName: z.string(), - status: StepStatusSchema, - input: SerializedDataSchema.optional(), - output: SerializedDataSchema.optional(), - /** - * The thrown value from a step_retrying or step_failed event, serialized - * via the workflow serialization pipeline. Tracks the most recent error - * the step encountered, which may be from a retry attempt (step_retrying) - * or the final failure (step_failed). - * - * To display the error to a user, hydrate it via `hydrateStepError` (with - * the encryption key if encryption is enabled). Observability tools cannot - * view the error without going through the decryption + hydration pipeline. - */ - error: SerializedDataSchema.optional(), - attempt: z.number(), - /** - * When the step first started executing. Set by the first step_started event - * and not updated on subsequent retries. - */ - startedAt: z.coerce.date().optional(), - completedAt: z.coerce.date().optional(), - createdAt: z.coerce.date(), - updatedAt: z.coerce.date(), - retryAfter: z.coerce.date().optional(), - // Optional in database for backwards compatibility, defaults to 1 (legacy) when reading - specVersion: z.number().optional(), -}); +export const StepSchema = z.compile( + z.object({ + runId: z.string(), + stepId: z.string(), + /** + * The machine-readable name of the step function. + * + * This field contains a structured identifier like `step//./src/workflows/order//processPayment` + * that encodes the step's module specifier and function name. + * + * Use `parseStepName()` from `@workflow/utils/parse-name` to extract: + * - `shortName`: User-friendly display name (e.g., `"processPayment"`) + * - `moduleSpecifier`: The module path or package (e.g., `"./src/workflows/order"`) + * - `functionName`: The full function path (e.g., `"processPayment"` or `"outer/nested"`) + * + * @example + * ```ts + * import { parseStepName } from "@workflow/utils/parse-name"; + * + * const parsed = parseStepName(step.stepName); + * // parsed.shortName → "processPayment" + * // parsed.moduleSpecifier → "./src/workflows/order" + * ``` + */ + stepName: z.string(), + status: StepStatusSchema, + input: SerializedDataSchema.optional(), + output: SerializedDataSchema.optional(), + /** + * The thrown value from a step_retrying or step_failed event, serialized + * via the workflow serialization pipeline. Tracks the most recent error + * the step encountered, which may be from a retry attempt (step_retrying) + * or the final failure (step_failed). + * + * To display the error to a user, hydrate it via `hydrateStepError` (with + * the encryption key if encryption is enabled). Observability tools cannot + * view the error without going through the decryption + hydration pipeline. + */ + error: SerializedDataSchema.optional(), + attempt: z.number(), + /** + * When the step first started executing. Set by the first step_started event + * and not updated on subsequent retries. + */ + startedAt: z.coerce.date().optional(), + completedAt: z.coerce.date().optional(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), + retryAfter: z.coerce.date().optional(), + // Optional in database for backwards compatibility, defaults to 1 (legacy) when reading + specVersion: z.number().optional(), + }) +); // Inferred types export type StepStatus = z.infer; diff --git a/packages/world/src/waits.ts b/packages/world/src/waits.ts index 547c9268d2..374db9486a 100644 --- a/packages/world/src/waits.ts +++ b/packages/world/src/waits.ts @@ -2,16 +2,18 @@ import { z } from 'zod'; export const WaitStatusSchema = z.enum(['waiting', 'completed']); -export const WaitSchema = z.object({ - waitId: z.string(), - runId: z.string(), - status: WaitStatusSchema, - resumeAt: z.coerce.date().optional(), - completedAt: z.coerce.date().optional(), - createdAt: z.coerce.date(), - updatedAt: z.coerce.date(), - specVersion: z.number().optional(), -}); +export const WaitSchema = z.compile( + z.object({ + waitId: z.string(), + runId: z.string(), + status: WaitStatusSchema, + resumeAt: z.coerce.date().optional(), + completedAt: z.coerce.date().optional(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), + specVersion: z.number().optional(), + }) +); export type WaitStatus = z.infer; export type Wait = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b98f4765a2..a8432f4bbf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,8 +58,8 @@ catalogs: specifier: ^4.1.10 version: 4.1.10 zod: - specifier: ~4.3.6 - version: 4.3.6 + specifier: ~4.5.4 + version: 4.5.4 overrides: '@vercel/queue>@vercel/oidc': 3.2.0 @@ -261,7 +261,7 @@ importers: version: link:../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 devDependencies: bun: specifier: ^1.3.0 @@ -298,14 +298,14 @@ importers: version: link:../utils zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 devDependencies: '@workflow/tsconfig': specifier: workspace:* version: link:../tsconfig ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) typescript: specifier: 'catalog:' version: 6.0.3 @@ -315,19 +315,19 @@ importers: optionalDependencies: '@ai-sdk/anthropic': specifier: ^3.0.0 - version: 3.0.58(zod@4.3.6) + version: 3.0.58(zod@4.5.4) '@ai-sdk/gateway': specifier: ^3.0.0 - version: 3.0.66(zod@4.3.6) + version: 3.0.66(zod@4.5.4) '@ai-sdk/google': specifier: ^3.0.0 - version: 3.0.43(zod@4.3.6) + version: 3.0.43(zod@4.5.4) '@ai-sdk/openai': specifier: ^3.0.0 - version: 3.0.41(zod@4.3.6) + version: 3.0.41(zod@4.5.4) '@ai-sdk/xai': specifier: ^3.0.0 - version: 3.0.67(zod@4.3.6) + version: 3.0.67(zod@4.5.4) packages/astro: dependencies: @@ -506,7 +506,7 @@ importers: version: 5.1.0 zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 devDependencies: '@types/node': specifier: 'catalog:' @@ -582,7 +582,7 @@ importers: version: 3.0.1 zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 devDependencies: '@opentelemetry/api': specifier: 1.9.1 @@ -637,13 +637,13 @@ importers: version: link:../next ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) workflow: specifier: workspace:* version: link:../workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 devDependencies: '@types/node': specifier: 'catalog:' @@ -1297,7 +1297,7 @@ importers: version: link:../tsconfig ai: specifier: 'catalog:' - version: 6.0.116(zod@4.4.3) + version: 6.0.116(zod@4.5.4) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1370,7 +1370,7 @@ importers: version: 3.0.1 zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 devDependencies: '@types/node': specifier: 'catalog:' @@ -1413,7 +1413,7 @@ importers: version: 7.29.0 zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 devDependencies: '@opentelemetry/api': specifier: 1.9.1 @@ -1477,7 +1477,7 @@ importers: version: 3.0.1 zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 devDependencies: '@testcontainers/postgresql': specifier: 11.12.0 @@ -1563,7 +1563,7 @@ importers: version: link:../workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 devDependencies: '@types/jsonlines': specifier: 0.1.5 @@ -1615,7 +1615,7 @@ importers: version: 8.20.0 zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 devDependencies: '@opentelemetry/api': specifier: 1.9.1 @@ -1761,7 +1761,7 @@ importers: version: link:../../packages/world-postgres ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) astro: specifier: ^7.0.6 version: 7.0.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@netlify/blobs@9.1.2)(@types/node@24.6.2)(@vercel/blob@2.0.0)(@vercel/functions@3.8.0(@aws-sdk/credential-provider-web-identity@3.972.49)(ws@8.20.0))(db0@0.3.4(better-sqlite3@11.10.0)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8)))(ioredis@5.10.1)(jiti@2.7.0)(rollup@4.62.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) @@ -1770,13 +1770,13 @@ importers: version: 4.2.0 openai: specifier: 6.9.0 - version: 6.9.0(ws@8.20.0)(zod@4.3.6) + version: 6.9.0(ws@8.20.0)(zod@4.5.4) workflow: specifier: workspace:* version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 workbench/example: dependencies: @@ -1794,7 +1794,7 @@ importers: version: link:../../packages/ai ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) lodash.chunk: specifier: ^4.2.0 version: 4.2.0 @@ -1803,13 +1803,13 @@ importers: version: 0.0.4 openai: specifier: ^6 - version: 6.1.0(ws@8.20.0)(zod@4.3.6) + version: 6.1.0(ws@8.20.0)(zod@4.5.4) workflow: specifier: workspace:* version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 devDependencies: '@types/lodash.chunk': specifier: ^4.2.9 @@ -1847,19 +1847,19 @@ importers: version: link:../../packages/world-postgres ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) lodash.chunk: specifier: ^4.2.0 version: 4.2.0 openai: specifier: ^6.1.0 - version: 6.6.0(ws@8.20.0)(zod@4.3.6) + version: 6.6.0(ws@8.20.0)(zod@4.5.4) workflow: specifier: workspace:* version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 workbench/fastify: dependencies: @@ -1881,13 +1881,13 @@ importers: version: link:../../packages/world-postgres ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) lodash.chunk: specifier: ^4.2.0 version: 4.2.0 openai: specifier: ^6.6.0 - version: 6.9.1(ws@8.20.0)(zod@4.3.6) + version: 6.9.1(ws@8.20.0)(zod@4.5.4) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1896,7 +1896,7 @@ importers: version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 workbench/hono: devDependencies: @@ -1908,7 +1908,7 @@ importers: version: link:../../packages/world-postgres ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) hono: specifier: ^4.12.27 version: 4.12.28 @@ -1920,13 +1920,13 @@ importers: version: 3.0.260610-beta(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.8.0(@aws-sdk/credential-provider-web-identity@3.972.49)(ws@8.20.0))(@vercel/queue@0.3.1)(better-sqlite3@11.10.0)(chokidar@5.0.0)(dotenv@17.3.1)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8))(giget@3.2.0)(ioredis@5.10.1)(jiti@2.7.0)(lru-cache@11.5.1)(rollup@4.62.2)(vite@8.1.3(@types/node@24.6.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) openai: specifier: ^6.6.0 - version: 6.6.0(ws@8.20.0)(zod@4.3.6) + version: 6.6.0(ws@8.20.0)(zod@4.5.4) workflow: specifier: workspace:* version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 workbench/nest: dependencies: @@ -1950,7 +1950,7 @@ importers: version: link:../../packages/world-postgres ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) express: specifier: ^5.2.1 version: 5.2.1 @@ -1959,7 +1959,7 @@ importers: version: 4.2.0 openai: specifier: ^6.1.0 - version: 6.9.1(ws@8.20.0)(zod@4.3.6) + version: 6.9.1(ws@8.20.0)(zod@4.5.4) reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -1990,13 +1990,13 @@ importers: version: 6.0.3 zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 workbench/nextjs-turbopack: dependencies: '@ai-sdk/react': specifier: 2.0.76 - version: 2.0.76(react@19.2.7)(zod@4.3.6) + version: 2.0.76(react@19.2.7)(zod@4.5.4) '@node-rs/xxhash': specifier: 1.7.6 version: 1.7.6 @@ -2029,7 +2029,7 @@ importers: version: link:../../packages/ai ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) class-variance-authority: specifier: 0.7.1 version: 0.7.1 @@ -2059,7 +2059,7 @@ importers: version: 16.2.11(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) openai: specifier: 6.9.1 - version: 6.9.1(ws@8.20.0)(zod@4.3.6) + version: 6.9.1(ws@8.20.0)(zod@4.5.4) radix-ui: specifier: 1.4.3 version: 1.4.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -2086,7 +2086,7 @@ importers: version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 devDependencies: '@tailwindcss/postcss': specifier: ^4 @@ -2120,7 +2120,7 @@ importers: dependencies: '@ai-sdk/react': specifier: 2.0.76 - version: 2.0.76(react@19.2.7)(zod@4.3.6) + version: 2.0.76(react@19.2.7)(zod@4.5.4) '@node-rs/xxhash': specifier: 1.7.6 version: 1.7.6 @@ -2153,7 +2153,7 @@ importers: version: link:../../packages/ai ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) class-variance-authority: specifier: 0.7.1 version: 0.7.1 @@ -2183,7 +2183,7 @@ importers: version: 16.2.11(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) openai: specifier: 6.9.1 - version: 6.9.1(ws@8.20.0)(zod@4.3.6) + version: 6.9.1(ws@8.20.0)(zod@4.5.4) radix-ui: specifier: 1.4.3 version: 1.4.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -2210,7 +2210,7 @@ importers: version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 devDependencies: '@tailwindcss/postcss': specifier: ^4 @@ -2250,7 +2250,7 @@ importers: version: link:../../packages/world-postgres ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) h3: specifier: ^1.15.5 version: 1.15.11 @@ -2262,13 +2262,13 @@ importers: version: 2.13.4(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.8.0(@aws-sdk/credential-provider-web-identity@3.972.49)(ws@8.20.0))(better-sqlite3@11.10.0)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8))(oxc-parser@0.133.0)(rolldown@1.1.4)(srvx@0.11.21) openai: specifier: ^6.6.0 - version: 6.6.0(ws@8.20.0)(zod@4.3.6) + version: 6.6.0(ws@8.20.0)(zod@4.5.4) workflow: specifier: workspace:* version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 workbench/nitro-v3: dependencies: @@ -2284,7 +2284,7 @@ importers: version: link:../../packages/world-postgres ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) lodash.chunk: specifier: ^4.2.0 version: 4.2.0 @@ -2293,7 +2293,7 @@ importers: version: 3.0.260610-beta(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.8.0(@aws-sdk/credential-provider-web-identity@3.972.49)(ws@8.20.0))(@vercel/queue@0.3.1)(better-sqlite3@11.10.0)(chokidar@5.0.0)(dotenv@17.3.1)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8))(giget@3.2.0)(ioredis@5.10.1)(jiti@2.7.0)(lru-cache@11.5.1)(rollup@4.62.2)(vite@8.1.3(@types/node@24.6.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) openai: specifier: ^6.1.0 - version: 6.6.0(ws@8.20.0)(zod@4.3.6) + version: 6.6.0(ws@8.20.0)(zod@4.5.4) rollup: specifier: ^4.62.2 version: 4.62.2 @@ -2302,7 +2302,7 @@ importers: version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 workbench/nuxt: dependencies: @@ -2321,7 +2321,7 @@ importers: version: link:../../packages/world-postgres ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) h3: specifier: ^1.15.5 version: 1.15.11 @@ -2333,7 +2333,7 @@ importers: version: 4.4.8(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0))(@biomejs/biome@2.4.4)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.19.0)(@vercel/blob@2.0.0)(@vercel/functions@3.8.0(@aws-sdk/credential-provider-web-identity@3.972.49)(ws@8.20.0))(@vue/compiler-sfc@3.5.35)(better-sqlite3@11.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@11.10.0)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8)))(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.1.4)(rollup-plugin-visualizer@7.0.1(rolldown@1.1.4)(rollup@4.62.2))(rollup@4.62.2)(terser@5.44.0)(tsx@4.20.6)(typescript@6.0.3)(vite@7.3.6(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0))(yaml@2.9.0) openai: specifier: ^6.6.0 - version: 6.6.0(ws@8.20.0)(zod@4.3.6) + version: 6.6.0(ws@8.20.0)(zod@4.5.4) vite: specifier: 7.3.6 version: 7.3.6(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) @@ -2342,7 +2342,7 @@ importers: version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 workbench/python: devDependencies: @@ -2372,7 +2372,7 @@ importers: dependencies: '@ai-sdk/react': specifier: 2.0.76 - version: 2.0.76(react@19.2.7)(zod@4.3.6) + version: 2.0.76(react@19.2.7)(zod@4.5.4) '@node-rs/xxhash': specifier: 1.7.6 version: 1.7.6 @@ -2396,7 +2396,7 @@ importers: version: link:../../packages/ai ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) exsolve: specifier: ^1.0.7 version: 1.0.7 @@ -2408,7 +2408,7 @@ importers: version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 devDependencies: '@sveltejs/kit': specifier: ^2.69.1 @@ -2558,7 +2558,7 @@ importers: version: link:../../packages/world-postgres ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) lodash.chunk: specifier: ^4.2.0 version: 4.2.0 @@ -2567,7 +2567,7 @@ importers: version: 3.0.260610-beta(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.8.0(@aws-sdk/credential-provider-web-identity@3.972.49)(ws@8.20.0))(@vercel/queue@0.3.1)(better-sqlite3@11.10.0)(chokidar@5.0.0)(dotenv@17.3.1)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8))(giget@3.2.0)(ioredis@5.10.1)(jiti@2.7.0)(lru-cache@11.5.1)(rollup@4.62.2)(vite@7.3.6(@types/node@24.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) openai: specifier: ^6.1.0 - version: 6.9.1(ws@8.20.0)(zod@4.3.6) + version: 6.9.1(ws@8.20.0)(zod@4.5.4) vite: specifier: ^7.3.6 version: 7.3.6(@types/node@24.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) @@ -2576,7 +2576,7 @@ importers: version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 workbench/vite: dependencies: @@ -2592,7 +2592,7 @@ importers: version: link:../../packages/world-postgres ai: specifier: 'catalog:' - version: 6.0.116(zod@4.3.6) + version: 6.0.116(zod@4.5.4) lodash.chunk: specifier: ^4.2.0 version: 4.2.0 @@ -2601,7 +2601,7 @@ importers: version: 3.0.260610-beta(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.8.0(@aws-sdk/credential-provider-web-identity@3.972.49)(ws@8.20.0))(@vercel/queue@0.3.1)(better-sqlite3@11.10.0)(chokidar@5.0.0)(dotenv@17.3.1)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8))(giget@3.2.0)(ioredis@5.10.1)(jiti@2.7.0)(lru-cache@11.5.1)(rollup@4.62.2)(vite@7.3.6(@types/node@24.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) openai: specifier: ^6.6.0 - version: 6.6.0(ws@8.20.0)(zod@4.3.6) + version: 6.6.0(ws@8.20.0)(zod@4.5.4) vite: specifier: ^7.3.6 version: 7.3.6(@types/node@24.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) @@ -2610,7 +2610,7 @@ importers: version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 workbench/vitest: devDependencies: @@ -2631,7 +2631,7 @@ importers: version: link:../../packages/workflow zod: specifier: 'catalog:' - version: 4.3.6 + version: 4.5.4 packages: @@ -17832,12 +17832,12 @@ packages: zod@4.1.11: resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==} - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} - zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zod@4.5.4: + resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} + zustand@4.5.7: resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} engines: {node: '>=12.7.0'} @@ -17858,89 +17858,75 @@ packages: snapshots: - '@ai-sdk/anthropic@3.0.58(zod@4.3.6)': + '@ai-sdk/anthropic@3.0.58(zod@4.5.4)': dependencies: '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.19(zod@4.3.6) - zod: 4.3.6 + '@ai-sdk/provider-utils': 4.0.19(zod@4.5.4) + zod: 4.5.4 optional: true - '@ai-sdk/gateway@2.0.0(zod@4.3.6)': + '@ai-sdk/gateway@2.0.0(zod@4.5.4)': dependencies: '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.12(zod@4.3.6) + '@ai-sdk/provider-utils': 3.0.12(zod@4.5.4) '@vercel/oidc': 3.0.3 - zod: 4.3.6 + zod: 4.5.4 - '@ai-sdk/gateway@3.0.143(zod@4.4.3)': + '@ai-sdk/gateway@3.0.143(zod@4.5.4)': dependencies: '@ai-sdk/provider': 3.0.13 - '@ai-sdk/provider-utils': 4.0.35(zod@4.4.3) + '@ai-sdk/provider-utils': 4.0.35(zod@4.5.4) '@vercel/oidc': 3.2.0 - zod: 4.4.3 + zod: 4.5.4 - '@ai-sdk/gateway@3.0.66(zod@4.3.6)': + '@ai-sdk/gateway@3.0.66(zod@4.5.4)': dependencies: '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.19(zod@4.3.6) + '@ai-sdk/provider-utils': 4.0.19(zod@4.5.4) '@vercel/oidc': 3.1.0 - zod: 4.3.6 + zod: 4.5.4 - '@ai-sdk/gateway@3.0.66(zod@4.4.3)': + '@ai-sdk/google@3.0.43(zod@4.5.4)': dependencies: '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.19(zod@4.4.3) - '@vercel/oidc': 3.1.0 - zod: 4.4.3 - - '@ai-sdk/google@3.0.43(zod@4.3.6)': - dependencies: - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.19(zod@4.3.6) - zod: 4.3.6 + '@ai-sdk/provider-utils': 4.0.19(zod@4.5.4) + zod: 4.5.4 optional: true - '@ai-sdk/openai-compatible@2.0.35(zod@4.3.6)': + '@ai-sdk/openai-compatible@2.0.35(zod@4.5.4)': dependencies: '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.19(zod@4.3.6) - zod: 4.3.6 + '@ai-sdk/provider-utils': 4.0.19(zod@4.5.4) + zod: 4.5.4 optional: true - '@ai-sdk/openai@3.0.41(zod@4.3.6)': + '@ai-sdk/openai@3.0.41(zod@4.5.4)': dependencies: '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.19(zod@4.3.6) - zod: 4.3.6 + '@ai-sdk/provider-utils': 4.0.19(zod@4.5.4) + zod: 4.5.4 optional: true - '@ai-sdk/provider-utils@3.0.12(zod@4.3.6)': + '@ai-sdk/provider-utils@3.0.12(zod@4.5.4)': dependencies: '@ai-sdk/provider': 2.0.0 '@standard-schema/spec': 1.0.0 eventsource-parser: 3.0.6 - zod: 4.3.6 - - '@ai-sdk/provider-utils@4.0.19(zod@4.3.6)': - dependencies: - '@ai-sdk/provider': 3.0.8 - '@standard-schema/spec': 1.1.0 - eventsource-parser: 3.0.6 - zod: 4.3.6 + zod: 4.5.4 - '@ai-sdk/provider-utils@4.0.19(zod@4.4.3)': + '@ai-sdk/provider-utils@4.0.19(zod@4.5.4)': dependencies: '@ai-sdk/provider': 3.0.8 '@standard-schema/spec': 1.1.0 eventsource-parser: 3.0.6 - zod: 4.4.3 + zod: 4.5.4 - '@ai-sdk/provider-utils@4.0.35(zod@4.4.3)': + '@ai-sdk/provider-utils@4.0.35(zod@4.5.4)': dependencies: '@ai-sdk/provider': 3.0.13 '@standard-schema/spec': 1.1.0 eventsource-parser: 3.1.0 - zod: 4.4.3 + zod: 4.5.4 '@ai-sdk/provider@2.0.0': dependencies: @@ -17954,32 +17940,32 @@ snapshots: dependencies: json-schema: 0.4.0 - '@ai-sdk/react@2.0.76(react@19.2.7)(zod@4.3.6)': + '@ai-sdk/react@2.0.76(react@19.2.7)(zod@4.5.4)': dependencies: - '@ai-sdk/provider-utils': 3.0.12(zod@4.3.6) - ai: 5.0.76(zod@4.3.6) + '@ai-sdk/provider-utils': 3.0.12(zod@4.5.4) + ai: 5.0.76(zod@4.5.4) react: 19.2.7 swr: 2.3.6(react@19.2.7) throttleit: 2.1.0 optionalDependencies: - zod: 4.3.6 + zod: 4.5.4 - '@ai-sdk/react@3.0.221(react@19.2.4)(zod@4.4.3)': + '@ai-sdk/react@3.0.221(react@19.2.4)(zod@4.5.4)': dependencies: - '@ai-sdk/provider-utils': 4.0.35(zod@4.4.3) - ai: 6.0.219(zod@4.4.3) + '@ai-sdk/provider-utils': 4.0.35(zod@4.5.4) + ai: 6.0.219(zod@4.5.4) react: 19.2.4 swr: 2.3.6(react@19.2.4) throttleit: 2.1.0 transitivePeerDependencies: - zod - '@ai-sdk/xai@3.0.67(zod@4.3.6)': + '@ai-sdk/xai@3.0.67(zod@4.5.4)': dependencies: - '@ai-sdk/openai-compatible': 2.0.35(zod@4.3.6) + '@ai-sdk/openai-compatible': 2.0.35(zod@4.5.4) '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.19(zod@4.3.6) - zod: 4.3.6 + '@ai-sdk/provider-utils': 4.0.19(zod@4.5.4) + zod: 4.5.4 optional: true '@alloc/quick-lru@5.2.0': {} @@ -20839,7 +20825,7 @@ snapshots: '@types/shimmer': 1.2.0 import-in-the-middle: 1.15.0 require-in-the-middle: 7.5.2 - semver: 7.8.2 + semver: 7.8.5 shimmer: 1.2.1 transitivePeerDependencies: - supports-color @@ -25967,7 +25953,7 @@ snapshots: jiti: 2.7.0 magic-string: 0.30.21 prettier: 3.8.1 - zod: 4.4.3 + zod: 4.5.4 transitivePeerDependencies: - supports-color @@ -25981,7 +25967,7 @@ snapshots: '@tanstack/router-utils': 1.162.2 chokidar: 5.0.0 unplugin: 3.0.0 - zod: 4.4.3 + zod: 4.5.4 optionalDependencies: '@tanstack/react-router': 1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7) vite: 7.3.6(@types/node@24.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) @@ -26032,7 +26018,7 @@ snapshots: ufo: 1.6.4 vitefu: 1.1.3(vite@7.3.6(@types/node@24.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) xmlbuilder2: 4.0.3 - zod: 4.4.3 + zod: 4.5.4 optionalDependencies: vite: 7.3.6(@types/node@24.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) transitivePeerDependencies: @@ -26596,7 +26582,7 @@ snapshots: '@vercel/geistdocs@1.23.1(67a87ea0ee5663b512e02db78181b04b)': dependencies: - '@ai-sdk/react': 3.0.221(react@19.2.4)(zod@4.4.3) + '@ai-sdk/react': 3.0.221(react@19.2.4)(zod@4.5.4) '@clack/prompts': 0.11.0 '@icons-pack/react-simple-icons': 13.8.0(react@19.2.4) '@orama/tokenizers': 3.1.18 @@ -26604,7 +26590,7 @@ snapshots: '@streamdown/code': 1.0.2(react@19.2.4) '@vercel/agent-readability': 0.6.0(@sveltejs/kit@2.69.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.46.4))(vite@7.3.6(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.46.4))(typescript@6.0.3)(vite@7.3.6(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)))(@vercel/functions@3.8.0(@aws-sdk/credential-provider-web-identity@3.972.49)(ws@8.20.0))(h3@1.15.11)(next@16.3.3(@opentelemetry/api@1.9.1)(@types/node@22.19.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) '@vercel/oidc': 3.8.2 - ai: 6.0.219(zod@4.4.3) + ai: 6.0.219(zod@4.5.4) class-variance-authority: 0.7.1 clsx: 2.1.1 commander: 14.0.3 @@ -26637,7 +26623,7 @@ snapshots: unist-util-visit: 5.1.0 use-stick-to-bottom: 1.1.1(react@19.2.4) vaul: 1.1.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - zod: 4.4.3 + zod: 4.5.4 transitivePeerDependencies: - '@emotion/is-prop-valid' - '@fumadocs/mdx-remote' @@ -27432,37 +27418,29 @@ snapshots: agent-base@7.1.4: {} - ai@5.0.76(zod@4.3.6): + ai@5.0.76(zod@4.5.4): dependencies: - '@ai-sdk/gateway': 2.0.0(zod@4.3.6) + '@ai-sdk/gateway': 2.0.0(zod@4.5.4) '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.12(zod@4.3.6) + '@ai-sdk/provider-utils': 3.0.12(zod@4.5.4) '@opentelemetry/api': 1.9.1 - zod: 4.3.6 + zod: 4.5.4 - ai@6.0.116(zod@4.3.6): + ai@6.0.116(zod@4.5.4): dependencies: - '@ai-sdk/gateway': 3.0.66(zod@4.3.6) + '@ai-sdk/gateway': 3.0.66(zod@4.5.4) '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.19(zod@4.3.6) + '@ai-sdk/provider-utils': 4.0.19(zod@4.5.4) '@opentelemetry/api': 1.9.1 - zod: 4.3.6 + zod: 4.5.4 - ai@6.0.116(zod@4.4.3): + ai@6.0.219(zod@4.5.4): dependencies: - '@ai-sdk/gateway': 3.0.66(zod@4.4.3) - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.19(zod@4.4.3) - '@opentelemetry/api': 1.9.1 - zod: 4.4.3 - - ai@6.0.219(zod@4.4.3): - dependencies: - '@ai-sdk/gateway': 3.0.143(zod@4.4.3) + '@ai-sdk/gateway': 3.0.143(zod@4.5.4) '@ai-sdk/provider': 3.0.13 - '@ai-sdk/provider-utils': 4.0.35(zod@4.4.3) + '@ai-sdk/provider-utils': 4.0.35(zod@4.5.4) '@opentelemetry/api': 1.9.1 - zod: 4.4.3 + zod: 4.5.4 ajv-formats@2.1.1(ajv@8.18.0): optionalDependencies: @@ -27673,7 +27651,7 @@ snapshots: vitefu: 1.1.3(vite@8.1.3(@types/node@22.19.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) xxhash-wasm: 1.1.0 yargs-parser: 22.0.0 - zod: 4.3.6 + zod: 4.4.3 optionalDependencies: sharp: 0.34.5 transitivePeerDependencies: @@ -27765,7 +27743,7 @@ snapshots: vitefu: 1.1.3(vite@8.1.3(@types/node@24.6.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) xxhash-wasm: 1.1.0 yargs-parser: 22.0.0 - zod: 4.3.6 + zod: 4.4.3 optionalDependencies: sharp: 0.34.5 transitivePeerDependencies: @@ -29823,7 +29801,7 @@ snapshots: unist-util-remove-position: 5.0.0 unist-util-visit: 5.0.0 vfile: 6.0.3 - zod: 4.3.6 + zod: 4.5.4 optionalDependencies: next: 16.3.3(@opentelemetry/api@1.9.1)(@types/node@22.19.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 @@ -32505,7 +32483,7 @@ snapshots: node-abi@3.89.0: dependencies: - semver: 7.8.2 + semver: 7.8.5 optional: true node-abort-controller@3.1.1: {} @@ -32920,25 +32898,25 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 - openai@6.1.0(ws@8.20.0)(zod@4.3.6): + openai@6.1.0(ws@8.20.0)(zod@4.5.4): optionalDependencies: ws: 8.20.0 - zod: 4.3.6 + zod: 4.5.4 - openai@6.6.0(ws@8.20.0)(zod@4.3.6): + openai@6.6.0(ws@8.20.0)(zod@4.5.4): optionalDependencies: ws: 8.20.0 - zod: 4.3.6 + zod: 4.5.4 - openai@6.9.0(ws@8.20.0)(zod@4.3.6): + openai@6.9.0(ws@8.20.0)(zod@4.5.4): optionalDependencies: ws: 8.20.0 - zod: 4.3.6 + zod: 4.5.4 - openai@6.9.1(ws@8.20.0)(zod@4.3.6): + openai@6.9.1(ws@8.20.0)(zod@4.5.4): optionalDependencies: ws: 8.20.0 - zod: 4.3.6 + zod: 4.5.4 optionator@0.9.4: dependencies: @@ -34778,8 +34756,7 @@ snapshots: semver@7.8.2: {} - semver@7.8.5: - optional: true + semver@7.8.5: {} send@1.2.0: dependencies: @@ -37099,10 +37076,10 @@ snapshots: zod@4.1.11: {} - zod@4.3.6: {} - zod@4.4.3: {} + zod@4.5.4: {} + zustand@4.5.7(@types/react@19.1.13)(react@19.1.0): dependencies: use-sync-external-store: 1.6.0(react@19.1.0) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c708418589..7b93a160a2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -24,7 +24,7 @@ catalog: ulid: ~3.0.1 undici: 7.29.0 vitest: ^4.1.10 - zod: ~4.3.6 + zod: ~4.5.4 onlyBuiltDependencies: - esbuild diff --git a/workbench/example/workflows/100_durable_agent_e2e.ts b/workbench/example/workflows/100_durable_agent_e2e.ts index 8c72eecbb6..04ba19cfe4 100644 --- a/workbench/example/workflows/100_durable_agent_e2e.ts +++ b/workbench/example/workflows/100_durable_agent_e2e.ts @@ -4,7 +4,7 @@ import { DurableAgent } from '@workflow/ai/agent'; import { mockSequenceModel, mockTextModel } from '@workflow/ai/test'; import { FatalError, getWritable } from 'workflow'; -import z from 'zod/v4'; +import z from 'zod'; // ============================================================================ // Tool step functions diff --git a/workbench/example/workflows/4_ai.ts b/workbench/example/workflows/4_ai.ts index 3da39ef204..e16b3be95e 100644 --- a/workbench/example/workflows/4_ai.ts +++ b/workbench/example/workflows/4_ai.ts @@ -1,6 +1,6 @@ import { generateText, stepCountIs } from 'ai'; import { FatalError } from 'workflow'; -import z from 'zod/v4'; +import z from 'zod'; async function getWeatherInformation({ city }: { city: string }) { 'use step'; diff --git a/workbench/nextjs-turbopack/workflows/agent_chat.ts b/workbench/nextjs-turbopack/workflows/agent_chat.ts index 001aed8ad7..8033078f38 100644 --- a/workbench/nextjs-turbopack/workflows/agent_chat.ts +++ b/workbench/nextjs-turbopack/workflows/agent_chat.ts @@ -5,7 +5,7 @@ import { type UIMessageChunk, } from 'ai'; import { getWritable } from 'workflow'; -import z from 'zod/v4'; +import z from 'zod'; // ============================================================================ // Tool step functions From 26de0c7fb778332b06b784c0978fdca9a6429875 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:43:05 -0700 Subject: [PATCH 02/13] Use stable Zod v4 namespace imports Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- .changeset/zod-45-compiled-schemas.md | 2 +- docs/source.config.ts | 2 +- packages/ai/src/agent/durable-agent-compat.test.ts | 2 +- packages/ai/src/agent/durable-agent-types.test.ts | 2 +- packages/ai/src/agent/durable-agent.test.ts | 2 +- packages/ai/src/agent/durable-agent.ts | 2 +- packages/ai/src/agent/telemetry.test.ts | 2 +- packages/ai/src/agent/tools-to-model-tools.test.ts | 2 +- packages/world-local/src/fs.test.ts | 2 +- packages/world-local/src/fs.ts | 2 +- packages/world-local/src/queue.test.ts | 2 +- packages/world-local/src/queue.ts | 2 +- packages/world-local/src/storage/events-storage.ts | 2 +- packages/world-local/src/storage/helpers.ts | 2 +- packages/world-local/src/storage/hook-index.ts | 2 +- packages/world-local/src/storage/hooks-storage.ts | 2 +- packages/world-local/src/streamer.ts | 2 +- packages/world-postgres/src/message.ts | 2 +- packages/world-postgres/src/queue.ts | 2 +- packages/world-postgres/src/streamer.ts | 2 +- packages/world-postgres/src/zod.ts | 2 +- packages/world-testing/src/server.mts | 2 +- packages/world-testing/src/util.mts | 2 +- packages/world-testing/workflows/hooks.ts | 2 +- packages/world-vercel/src/encryption.ts | 2 +- packages/world-vercel/src/event-retry.ts | 2 +- packages/world-vercel/src/events-v4.ts | 2 +- packages/world-vercel/src/frames.ts | 2 +- packages/world-vercel/src/hooks.ts | 2 +- packages/world-vercel/src/queue.ts | 2 +- packages/world-vercel/src/resolve-latest-deployment.ts | 2 +- packages/world-vercel/src/runs.ts | 2 +- packages/world-vercel/src/steps.ts | 2 +- packages/world-vercel/src/streamer.ts | 2 +- packages/world-vercel/src/trace-propagation.test.ts | 2 +- packages/world-vercel/src/utils.test.ts | 2 +- packages/world-vercel/src/utils.ts | 2 +- packages/world-vercel/src/ws-protocol-conformance.test.ts | 2 +- packages/world/src/analytics.ts | 2 +- packages/world/src/attributes.ts | 2 +- packages/world/src/events.ts | 2 +- packages/world/src/hooks.ts | 2 +- packages/world/src/queue.ts | 2 +- packages/world/src/runs.ts | 2 +- packages/world/src/schema-compilation.test.ts | 2 +- packages/world/src/serialization.ts | 2 +- packages/world/src/shared.test.ts | 2 +- packages/world/src/shared.ts | 2 +- packages/world/src/steps.ts | 2 +- packages/world/src/ulid.ts | 2 +- packages/world/src/waits.ts | 2 +- workbench/example/workflows/100_durable_agent_e2e.ts | 2 +- workbench/example/workflows/4_ai.ts | 2 +- workbench/nextjs-turbopack/workflows/agent_chat.ts | 2 +- workbench/vitest/workflows/cookbook/child-workflows.ts | 2 +- 55 files changed, 55 insertions(+), 55 deletions(-) diff --git a/.changeset/zod-45-compiled-schemas.md b/.changeset/zod-45-compiled-schemas.md index 11afa980e4..373abe8cb6 100644 --- a/.changeset/zod-45-compiled-schemas.md +++ b/.changeset/zod-45-compiled-schemas.md @@ -10,4 +10,4 @@ 'workflow': patch --- -Upgrade runtime validation to Zod 4.5 and precompile hot-path World schemas. +Upgrade runtime validation to Zod 4.5, use the stable Zod 4 subpath, and precompile hot-path World schemas. diff --git a/docs/source.config.ts b/docs/source.config.ts index d1245665da..a834cd59cb 100644 --- a/docs/source.config.ts +++ b/docs/source.config.ts @@ -4,7 +4,7 @@ import { geistdocsMetaSchema, } from '@vercel/geistdocs/source-config'; import { defineDocs } from 'fumadocs-mdx/config'; -import { z } from 'zod'; +import * as z from 'zod/v4'; // You can customise Zod schemas for frontmatter and `meta.json` here // see https://fumadocs.dev/docs/mdx/collections diff --git a/packages/ai/src/agent/durable-agent-compat.test.ts b/packages/ai/src/agent/durable-agent-compat.test.ts index e075b3d355..9c70842f90 100644 --- a/packages/ai/src/agent/durable-agent-compat.test.ts +++ b/packages/ai/src/agent/durable-agent-compat.test.ts @@ -16,7 +16,7 @@ import type { UIMessageChunk } from 'ai'; import { tool } from 'ai'; import { convertArrayToReadableStream, MockLanguageModelV3 } from 'ai/test'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import { DurableAgent } from './durable-agent.js'; // ============================================================================ diff --git a/packages/ai/src/agent/durable-agent-types.test.ts b/packages/ai/src/agent/durable-agent-types.test.ts index 964a1481a0..d20d36b605 100644 --- a/packages/ai/src/agent/durable-agent-types.test.ts +++ b/packages/ai/src/agent/durable-agent-types.test.ts @@ -1,6 +1,6 @@ import { type InferUITools, tool, type UIMessage } from 'ai'; import { describe, expectTypeOf, it } from 'vitest'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import { DurableAgent, type InferDurableAgentTools, diff --git a/packages/ai/src/agent/durable-agent.test.ts b/packages/ai/src/agent/durable-agent.test.ts index 5e7040c920..8d5b3abd43 100644 --- a/packages/ai/src/agent/durable-agent.test.ts +++ b/packages/ai/src/agent/durable-agent.test.ts @@ -13,7 +13,7 @@ import type { } from '@ai-sdk/provider'; import type { StepResult, ToolSet } from 'ai'; import { describe, expect, it, vi } from 'vitest'; -import { z } from 'zod'; +import * as z from 'zod/v4'; class FatalError extends Error { constructor(message: string) { diff --git a/packages/ai/src/agent/durable-agent.ts b/packages/ai/src/agent/durable-agent.ts index 383a2c9e15..dc78668a53 100644 --- a/packages/ai/src/agent/durable-agent.ts +++ b/packages/ai/src/agent/durable-agent.ts @@ -554,7 +554,7 @@ export interface DurableAgentStreamOptions< * @example * ```typescript * import { Output } from '@workflow/ai'; - * import { z } from 'zod'; + * import * as z from 'zod/v4'; * * const result = await agent.stream({ * messages: [...], diff --git a/packages/ai/src/agent/telemetry.test.ts b/packages/ai/src/agent/telemetry.test.ts index 716a123035..82416000e1 100644 --- a/packages/ai/src/agent/telemetry.test.ts +++ b/packages/ai/src/agent/telemetry.test.ts @@ -7,7 +7,7 @@ import type { LanguageModelV3StreamPart, } from '@ai-sdk/provider'; import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; -import { z } from 'zod'; +import * as z from 'zod/v4'; // ── Mock span that captures all setAttributes calls ────────────────────── function createMockSpan() { diff --git a/packages/ai/src/agent/tools-to-model-tools.test.ts b/packages/ai/src/agent/tools-to-model-tools.test.ts index 605205a36e..ee283604b4 100644 --- a/packages/ai/src/agent/tools-to-model-tools.test.ts +++ b/packages/ai/src/agent/tools-to-model-tools.test.ts @@ -1,6 +1,6 @@ import { tool } from 'ai'; import { describe, expect, it } from 'vitest'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import { toolsToModelTools } from './tools-to-model-tools.js'; describe('toolsToModelTools', () => { diff --git a/packages/world-local/src/fs.test.ts b/packages/world-local/src/fs.test.ts index 1f647b5bb9..9d7bd6d46b 100644 --- a/packages/world-local/src/fs.test.ts +++ b/packages/world-local/src/fs.test.ts @@ -14,7 +14,7 @@ import { it, vi, } from 'vitest'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import { UnwritableDataDirError } from './build-target-mismatch.js'; import { assertSafeEntityId, diff --git a/packages/world-local/src/fs.ts b/packages/world-local/src/fs.ts index b0cbf7413c..a290f7eb9e 100644 --- a/packages/world-local/src/fs.ts +++ b/packages/world-local/src/fs.ts @@ -4,7 +4,7 @@ import { EntityConflictError, WorkflowWorldError } from '@workflow/errors'; import { globalSingleton } from '@workflow/utils'; import type { PaginatedResponse } from '@workflow/world'; import { monotonicFactory } from 'ulid'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import { isUnwritableDirCode, UnwritableDataDirError, diff --git a/packages/world-local/src/queue.test.ts b/packages/world-local/src/queue.test.ts index f932fa106a..cff5ba5091 100644 --- a/packages/world-local/src/queue.test.ts +++ b/packages/world-local/src/queue.test.ts @@ -4,7 +4,7 @@ import { setWorkflowBasePath } from '@workflow/utils'; import type { WorkflowInvokePayload } from '@workflow/world'; import { MessageId, NODE_HTTP_ENV_VAR, ValidQueueName } from '@workflow/world'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import { createQueue, DEFAULT_BODY_TIMEOUT_MS, diff --git a/packages/world-local/src/queue.ts b/packages/world-local/src/queue.ts index 3a221742af..eb5526c69c 100644 --- a/packages/world-local/src/queue.ts +++ b/packages/world-local/src/queue.ts @@ -17,7 +17,7 @@ import { import { Sema } from 'async-sema'; import { monotonicFactory } from 'ulid'; import { Agent } from 'undici'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import type { Config } from './config.js'; import { resolveBaseUrl, resolveDirectBaseUrl } from './config.js'; import { jsonReplacer, jsonReviver } from './fs.js'; diff --git a/packages/world-local/src/storage/events-storage.ts b/packages/world-local/src/storage/events-storage.ts index 015207e75e..cff62f8d21 100644 --- a/packages/world-local/src/storage/events-storage.ts +++ b/packages/world-local/src/storage/events-storage.ts @@ -53,7 +53,7 @@ import { WaitSchema, WorkflowRunSchema, } from '@workflow/world'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import { DEFAULT_RESOLVE_DATA_OPTION } from '../config.js'; import { assertSafeEntityId, diff --git a/packages/world-local/src/storage/helpers.ts b/packages/world-local/src/storage/helpers.ts index 9584fec930..97946f35f1 100644 --- a/packages/world-local/src/storage/helpers.ts +++ b/packages/world-local/src/storage/helpers.ts @@ -6,7 +6,7 @@ import { globalSingleton } from '@workflow/utils'; import { eventIdToSlot } from '@workflow/world'; import { lock } from 'proper-lockfile'; import { decodeTime, monotonicFactory } from 'ulid'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import { deleteJSON, hasTag, diff --git a/packages/world-local/src/storage/hook-index.ts b/packages/world-local/src/storage/hook-index.ts index 421e0e2fb3..a0ad1df6cd 100644 --- a/packages/world-local/src/storage/hook-index.ts +++ b/packages/world-local/src/storage/hook-index.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { globalSingleton } from '@workflow/utils'; import type { Event } from '@workflow/world'; import { EventSchema, HookSchema } from '@workflow/world'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import { assertSafeEntityId, deleteJSON, diff --git a/packages/world-local/src/storage/hooks-storage.ts b/packages/world-local/src/storage/hooks-storage.ts index 79f13a5946..b85405c2ff 100644 --- a/packages/world-local/src/storage/hooks-storage.ts +++ b/packages/world-local/src/storage/hooks-storage.ts @@ -14,7 +14,7 @@ import { isTerminalWorkflowRunStatus, WorkflowRunSchema, } from '@workflow/world'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import { DEFAULT_RESOLVE_DATA_OPTION } from '../config.js'; import { assertSafeEntityId, diff --git a/packages/world-local/src/streamer.ts b/packages/world-local/src/streamer.ts index 195a4b08e3..73230ca5e8 100644 --- a/packages/world-local/src/streamer.ts +++ b/packages/world-local/src/streamer.ts @@ -9,7 +9,7 @@ import type { StreamInfoResponse, } from '@workflow/world'; import { monotonicFactory } from 'ulid'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import { assertSafeEntityId, readBuffer, diff --git a/packages/world-postgres/src/message.ts b/packages/world-postgres/src/message.ts index f672e34dd5..cdb9bad59b 100644 --- a/packages/world-postgres/src/message.ts +++ b/packages/world-postgres/src/message.ts @@ -1,5 +1,5 @@ import { MessageId } from '@workflow/world'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import { Base64Buffer } from './zod.js'; /** diff --git a/packages/world-postgres/src/queue.ts b/packages/world-postgres/src/queue.ts index 0c77f3da6d..0ff10d7550 100644 --- a/packages/world-postgres/src/queue.ts +++ b/packages/world-postgres/src/queue.ts @@ -29,7 +29,7 @@ import { } from 'graphile-worker'; import type { Pool } from 'pg'; import { monotonicFactory } from 'ulid'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import type { PostgresWorldConfig } from './config.js'; import { MessageData } from './message.js'; diff --git a/packages/world-postgres/src/streamer.ts b/packages/world-postgres/src/streamer.ts index faadb72b65..66343a1056 100644 --- a/packages/world-postgres/src/streamer.ts +++ b/packages/world-postgres/src/streamer.ts @@ -8,7 +8,7 @@ import type { import { and, asc, eq, gt, sql } from 'drizzle-orm'; import { Client, type Pool } from 'pg'; import { monotonicFactory } from 'ulid'; -import * as z from 'zod'; +import * as z from 'zod/v4'; import { type Drizzle, Schema } from './drizzle/index.js'; import { Mutex } from './util.js'; diff --git a/packages/world-postgres/src/zod.ts b/packages/world-postgres/src/zod.ts index cd52342ef1..424e16e386 100644 --- a/packages/world-postgres/src/zod.ts +++ b/packages/world-postgres/src/zod.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod/v4'; export const Base64Buffer = z.codec(z.base64(), z.instanceof(Buffer), { decode(b64) { diff --git a/packages/world-testing/src/server.mts b/packages/world-testing/src/server.mts index 069d1e1588..1cb52bdc95 100644 --- a/packages/world-testing/src/server.mts +++ b/packages/world-testing/src/server.mts @@ -3,7 +3,7 @@ import { serve } from '@hono/node-server'; import { Hono } from 'hono'; import { getHookByToken, getRun, resumeHook, start } from 'workflow/api'; import { getWorld } from 'workflow/runtime'; -import * as z from 'zod'; +import * as z from 'zod/v4'; import { POST as flowPOST } from '../.well-known/workflow/v1/flow.mjs'; import manifest from '../.well-known/workflow/v1/manifest.json' with { type: 'json', diff --git a/packages/world-testing/src/util.mts b/packages/world-testing/src/util.mts index 17e1ca45ca..2e87dfa5e6 100644 --- a/packages/world-testing/src/util.mts +++ b/packages/world-testing/src/util.mts @@ -11,7 +11,7 @@ import chalk, { type ChalkInstance } from 'chalk'; import jsonlines from 'jsonlines'; import { assert, onTestFailed, onTestFinished } from 'vitest'; import type { TypedHook } from 'workflow'; -import * as z from 'zod'; +import * as z from 'zod/v4'; import type manifest from '../.well-known/workflow/v1/manifest.json'; export const Control = z.object({ diff --git a/packages/world-testing/workflows/hooks.ts b/packages/world-testing/workflows/hooks.ts index f3ab531cf4..71a9bdff73 100644 --- a/packages/world-testing/workflows/hooks.ts +++ b/packages/world-testing/workflows/hooks.ts @@ -1,5 +1,5 @@ import { defineHook, getWritable } from '@workflow/core'; -import * as z from 'zod'; +import * as z from 'zod/v4'; export const Hook = defineHook({ schema: z.object({ diff --git a/packages/world-vercel/src/encryption.ts b/packages/world-vercel/src/encryption.ts index c12170481b..e23ba39946 100644 --- a/packages/world-vercel/src/encryption.ts +++ b/packages/world-vercel/src/encryption.ts @@ -12,7 +12,7 @@ import { webcrypto } from 'node:crypto'; import type { WorkflowRun, World } from '@workflow/world'; -import * as z from 'zod'; +import * as z from 'zod/v4'; import { getDispatcher } from './http-client.js'; import { instrumentedFetch, resolveVercelApiToken } from './http-core.js'; diff --git a/packages/world-vercel/src/event-retry.ts b/packages/world-vercel/src/event-retry.ts index 6d959ada52..8b20bbe8e3 100644 --- a/packages/world-vercel/src/event-retry.ts +++ b/packages/world-vercel/src/event-retry.ts @@ -70,7 +70,7 @@ import { WorkflowWorldError, } from '@workflow/errors'; import type { EventTypeSchema } from '@workflow/world'; -import type { z } from 'zod'; +import type * as z from 'zod/v4'; /** Every event type the world knows about (includes the server-only * `hook_conflict`, which the SDK never POSTs). */ diff --git a/packages/world-vercel/src/events-v4.ts b/packages/world-vercel/src/events-v4.ts index cc325e82e8..5af073e170 100644 --- a/packages/world-vercel/src/events-v4.ts +++ b/packages/world-vercel/src/events-v4.ts @@ -37,7 +37,7 @@ import { WorkflowRunSchema, } from '@workflow/world'; import { decode } from 'cbor-x'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import { type DecodedFrame, decodeFrames, diff --git a/packages/world-vercel/src/frames.ts b/packages/world-vercel/src/frames.ts index 70bf6dd0df..f2bba55b81 100644 --- a/packages/world-vercel/src/frames.ts +++ b/packages/world-vercel/src/frames.ts @@ -9,7 +9,7 @@ */ import { decode, encode } from 'cbor-x'; -import { z } from 'zod'; +import * as z from 'zod/v4'; export const V4_FRAME_CONTENT_TYPE = 'application/vnd.workflow.v4-frames'; diff --git a/packages/world-vercel/src/hooks.ts b/packages/world-vercel/src/hooks.ts index 37cad7fa88..232c8d0fc5 100644 --- a/packages/world-vercel/src/hooks.ts +++ b/packages/world-vercel/src/hooks.ts @@ -7,7 +7,7 @@ import type { PaginatedResponse, } from '@workflow/world'; import { HookSchema, PaginatedResponseSchema } from '@workflow/world'; -import z from 'zod'; +import * as z from 'zod/v4'; import { normalizeHookData } from './serialized-data.js'; import type { APIConfig } from './utils.js'; import { DEFAULT_RESOLVE_DATA_OPTION, makeRequest } from './utils.js'; diff --git a/packages/world-vercel/src/queue.ts b/packages/world-vercel/src/queue.ts index 55dca2d7e7..49d191a35b 100644 --- a/packages/world-vercel/src/queue.ts +++ b/packages/world-vercel/src/queue.ts @@ -13,7 +13,7 @@ import { ValidQueueName, } from '@workflow/world'; import { decode as cborDecode, encode as cborEncode } from 'cbor-x'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import { missingDeploymentIdMessage } from './deployment-id.js'; import { getQueueDispatcher } from './http-client.js'; import { decode as decodeTaggedRunId } from './run-id/index.js'; diff --git a/packages/world-vercel/src/resolve-latest-deployment.ts b/packages/world-vercel/src/resolve-latest-deployment.ts index d68b899266..8020dc1591 100644 --- a/packages/world-vercel/src/resolve-latest-deployment.ts +++ b/packages/world-vercel/src/resolve-latest-deployment.ts @@ -7,7 +7,7 @@ */ import { getVercelOidcToken } from '@vercel/oidc'; -import * as z from 'zod'; +import * as z from 'zod/v4'; import { missingDeploymentIdMessage } from './deployment-id.js'; import { getDispatcher } from './http-client.js'; import { instrumentedFetch, resolveVercelApiToken } from './http-core.js'; diff --git a/packages/world-vercel/src/runs.ts b/packages/world-vercel/src/runs.ts index bfbb841594..3e85688967 100644 --- a/packages/world-vercel/src/runs.ts +++ b/packages/world-vercel/src/runs.ts @@ -19,7 +19,7 @@ import { WorkflowRunBaseSchema, type WorkflowRunWithoutData, } from '@workflow/world'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import { getRequestTimeoutMs } from './http-core.js'; import { normalizeWorkflowRunData } from './serialized-data.js'; import type { APIConfig } from './utils.js'; diff --git a/packages/world-vercel/src/steps.ts b/packages/world-vercel/src/steps.ts index cd395ab64e..d8f352db00 100644 --- a/packages/world-vercel/src/steps.ts +++ b/packages/world-vercel/src/steps.ts @@ -10,7 +10,7 @@ import { type StepWithoutData, type UpdateStepRequest, } from '@workflow/world'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import { normalizeStepData } from './serialized-data.js'; import type { APIConfig } from './utils.js'; import { diff --git a/packages/world-vercel/src/streamer.ts b/packages/world-vercel/src/streamer.ts index f7e07f8409..6814898a67 100644 --- a/packages/world-vercel/src/streamer.ts +++ b/packages/world-vercel/src/streamer.ts @@ -5,7 +5,7 @@ import { type Streamer, type StreamInfoResponse, } from '@workflow/world'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import { getStreamCloseDispatcher, getStreamDispatcher, diff --git a/packages/world-vercel/src/trace-propagation.test.ts b/packages/world-vercel/src/trace-propagation.test.ts index 9966e3c641..73208c17f0 100644 --- a/packages/world-vercel/src/trace-propagation.test.ts +++ b/packages/world-vercel/src/trace-propagation.test.ts @@ -21,7 +21,7 @@ import { it, vi, } from 'vitest'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import { createHookReceivedPreloadEventV4, getWorkflowRunEventsV4, diff --git a/packages/world-vercel/src/utils.test.ts b/packages/world-vercel/src/utils.test.ts index 4dec341201..bb3560af26 100644 --- a/packages/world-vercel/src/utils.test.ts +++ b/packages/world-vercel/src/utils.test.ts @@ -4,7 +4,7 @@ import { PreconditionFailedError, StreamExpiredError } from '@workflow/errors'; import { NODE_HTTP_ENV_VAR } from '@workflow/world'; import { encode } from 'cbor-x'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import { getHeaders, getHttpConfig, diff --git a/packages/world-vercel/src/utils.ts b/packages/world-vercel/src/utils.ts index f89873864b..d34894f72d 100644 --- a/packages/world-vercel/src/utils.ts +++ b/packages/world-vercel/src/utils.ts @@ -5,7 +5,7 @@ import { WorkflowWorldError } from '@workflow/errors'; import type { SerializedData } from '@workflow/world'; import { nodeHttpFetch } from '@workflow/world/node-http.js'; import { decode, encode } from 'cbor-x'; -import type { z } from 'zod'; +import type * as z from 'zod/v4'; import { getDispatcher, getNodeHttpAgents, diff --git a/packages/world-vercel/src/ws-protocol-conformance.test.ts b/packages/world-vercel/src/ws-protocol-conformance.test.ts index deffac7c67..ef59eb9562 100644 --- a/packages/world-vercel/src/ws-protocol-conformance.test.ts +++ b/packages/world-vercel/src/ws-protocol-conformance.test.ts @@ -22,7 +22,7 @@ import { EntityConflictError } from '@workflow/errors'; import { decode, encode } from 'cbor-x'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import { createWorkflowRunEventV4 } from './events-v4.js'; import { type DecodedFrame, decodeFrames, encodeFrame } from './frames.js'; diff --git a/packages/world/src/analytics.ts b/packages/world/src/analytics.ts index 13eeabef59..a57e25809e 100644 --- a/packages/world/src/analytics.ts +++ b/packages/world/src/analytics.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod/v4'; import { EventTypeSchema } from './events.js'; import { WorkflowRunStatusSchema } from './runs.js'; import type { PaginatedResponse, PaginationOptions } from './shared.js'; diff --git a/packages/world/src/attributes.ts b/packages/world/src/attributes.ts index 8a5ec26382..e6e4ee13ac 100644 --- a/packages/world/src/attributes.ts +++ b/packages/world/src/attributes.ts @@ -1,4 +1,4 @@ -import * as z from 'zod'; +import * as z from 'zod/v4'; import { ATTRIBUTE_KEY_MAX_LENGTH, diff --git a/packages/world/src/events.ts b/packages/world/src/events.ts index bd5bc194f8..da7e01eef3 100644 --- a/packages/world/src/events.ts +++ b/packages/world/src/events.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod/v4'; import { AttributeChangesSchema } from './attributes.js'; import { getEventDataRefFields } from './event-metadata.js'; import type { Hook } from './hooks.js'; diff --git a/packages/world/src/hooks.ts b/packages/world/src/hooks.ts index da6d054c21..9f3913c2a4 100644 --- a/packages/world/src/hooks.ts +++ b/packages/world/src/hooks.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod/v4'; import { TraceCarrierSchema } from './queue.js'; import type { SerializedData } from './serialization.js'; import { SerializedDataSchema } from './serialization.js'; diff --git a/packages/world/src/queue.ts b/packages/world/src/queue.ts index 9431071778..60508d805d 100644 --- a/packages/world/src/queue.ts +++ b/packages/world/src/queue.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod/v4'; export type QueueKind = 'workflow'; diff --git a/packages/world/src/runs.ts b/packages/world/src/runs.ts index 2d1465f211..a5539c5249 100644 --- a/packages/world/src/runs.ts +++ b/packages/world/src/runs.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod/v4'; import { type SerializedData, SerializedDataSchema } from './serialization.js'; import type { PaginationOptions, ResolveData } from './shared.js'; diff --git a/packages/world/src/schema-compilation.test.ts b/packages/world/src/schema-compilation.test.ts index c73f490bf8..341ca29e44 100644 --- a/packages/world/src/schema-compilation.test.ts +++ b/packages/world/src/schema-compilation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import type { z } from 'zod'; +import type * as z from 'zod/v4'; import { AnalyticsAttributeKeySchema, AnalyticsEventSchema, diff --git a/packages/world/src/serialization.ts b/packages/world/src/serialization.ts index c78010caec..64f1990369 100644 --- a/packages/world/src/serialization.ts +++ b/packages/world/src/serialization.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod/v4'; /** * Binary serialized data using devalue format. diff --git a/packages/world/src/shared.test.ts b/packages/world/src/shared.test.ts index 4dd7a6df37..9a928c501f 100644 --- a/packages/world/src/shared.test.ts +++ b/packages/world/src/shared.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import { PaginatedResponseSchema } from './shared.js'; describe('PaginatedResponseSchema', () => { diff --git a/packages/world/src/shared.ts b/packages/world/src/shared.ts index 465e6a98bd..f92d598893 100644 --- a/packages/world/src/shared.ts +++ b/packages/world/src/shared.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod/v4'; export const zodJsonSchema: z.ZodType = z.lazy(() => { return z.union([ diff --git a/packages/world/src/steps.ts b/packages/world/src/steps.ts index ce3e995ad9..a4b5a7ba94 100644 --- a/packages/world/src/steps.ts +++ b/packages/world/src/steps.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod/v4'; import { type SerializedData, SerializedDataSchema } from './serialization.js'; import type { PaginationOptions, ResolveData } from './shared.js'; diff --git a/packages/world/src/ulid.ts b/packages/world/src/ulid.ts index 9bd09c1cfe..e8be97fe3f 100644 --- a/packages/world/src/ulid.ts +++ b/packages/world/src/ulid.ts @@ -1,5 +1,5 @@ import { decodeTime } from 'ulid'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import { isSlotBody } from './slot-identity.js'; const UlidSchema = z.string().ulid(); diff --git a/packages/world/src/waits.ts b/packages/world/src/waits.ts index 374db9486a..94a15b4a6e 100644 --- a/packages/world/src/waits.ts +++ b/packages/world/src/waits.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod/v4'; export const WaitStatusSchema = z.enum(['waiting', 'completed']); diff --git a/workbench/example/workflows/100_durable_agent_e2e.ts b/workbench/example/workflows/100_durable_agent_e2e.ts index 04ba19cfe4..c1a5429bdb 100644 --- a/workbench/example/workflows/100_durable_agent_e2e.ts +++ b/workbench/example/workflows/100_durable_agent_e2e.ts @@ -4,7 +4,7 @@ import { DurableAgent } from '@workflow/ai/agent'; import { mockSequenceModel, mockTextModel } from '@workflow/ai/test'; import { FatalError, getWritable } from 'workflow'; -import z from 'zod'; +import * as z from 'zod/v4'; // ============================================================================ // Tool step functions diff --git a/workbench/example/workflows/4_ai.ts b/workbench/example/workflows/4_ai.ts index e16b3be95e..06affd0292 100644 --- a/workbench/example/workflows/4_ai.ts +++ b/workbench/example/workflows/4_ai.ts @@ -1,6 +1,6 @@ import { generateText, stepCountIs } from 'ai'; import { FatalError } from 'workflow'; -import z from 'zod'; +import * as z from 'zod/v4'; async function getWeatherInformation({ city }: { city: string }) { 'use step'; diff --git a/workbench/nextjs-turbopack/workflows/agent_chat.ts b/workbench/nextjs-turbopack/workflows/agent_chat.ts index 8033078f38..af97677768 100644 --- a/workbench/nextjs-turbopack/workflows/agent_chat.ts +++ b/workbench/nextjs-turbopack/workflows/agent_chat.ts @@ -5,7 +5,7 @@ import { type UIMessageChunk, } from 'ai'; import { getWritable } from 'workflow'; -import z from 'zod'; +import * as z from 'zod/v4'; // ============================================================================ // Tool step functions diff --git a/workbench/vitest/workflows/cookbook/child-workflows.ts b/workbench/vitest/workflows/cookbook/child-workflows.ts index b11d6f65b8..10d44dec86 100644 --- a/workbench/vitest/workflows/cookbook/child-workflows.ts +++ b/workbench/vitest/workflows/cookbook/child-workflows.ts @@ -1,6 +1,6 @@ import { defineHook } from 'workflow'; import { start } from 'workflow/api'; -import { z } from 'zod'; +import * as z from 'zod/v4'; async function processItem(item: string): Promise { 'use step'; From def7526ddf6f614e1a04e1540cc402e4e835ee79 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:09:04 -0700 Subject: [PATCH 03/13] Clarify Zod compilation changeset Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- .changeset/zod-45-compiled-schemas.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/zod-45-compiled-schemas.md b/.changeset/zod-45-compiled-schemas.md index 373abe8cb6..d2e97e3cfb 100644 --- a/.changeset/zod-45-compiled-schemas.md +++ b/.changeset/zod-45-compiled-schemas.md @@ -10,4 +10,4 @@ 'workflow': patch --- -Upgrade runtime validation to Zod 4.5, use the stable Zod 4 subpath, and precompile hot-path World schemas. +Upgrade runtime validation to Zod 4.5, use the stable Zod 4 subpath, and enable compilation on Zod schemas. From ca4ea2adf08e4fbb2b0754217777a37a202027b3 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:31:16 -0700 Subject: [PATCH 04/13] Enable compilation for all Zod schemas Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- .changeset/zod-45-compiled-schemas.md | 2 +- packages/world-postgres/src/zod.ts | 18 +- packages/world-testing/src/server.mts | 44 +- packages/world-testing/src/util.mts | 16 +- packages/world-testing/workflows/hooks.ts | 12 +- packages/world-vercel/src/events-v4.ts | 52 +- packages/world-vercel/src/runs.ts | 22 +- packages/world/src/analytics.ts | 20 +- packages/world/src/attributes.ts | 46 +- packages/world/src/events.ts | 693 ++++++++++-------- packages/world/src/hooks.ts | 64 +- packages/world/src/queue.ts | 259 +++---- packages/world/src/runs.ts | 196 +++-- packages/world/src/schema-compilation.test.ts | 156 ++-- packages/world/src/serialization.ts | 15 +- packages/world/src/shared.ts | 18 +- packages/world/src/steps.ts | 18 +- packages/world/src/ulid.ts | 6 +- packages/world/src/waits.ts | 2 +- 19 files changed, 899 insertions(+), 760 deletions(-) diff --git a/.changeset/zod-45-compiled-schemas.md b/.changeset/zod-45-compiled-schemas.md index d2e97e3cfb..f32e14c406 100644 --- a/.changeset/zod-45-compiled-schemas.md +++ b/.changeset/zod-45-compiled-schemas.md @@ -10,4 +10,4 @@ 'workflow': patch --- -Upgrade runtime validation to Zod 4.5, use the stable Zod 4 subpath, and enable compilation on Zod schemas. +Upgrade runtime validation to Zod 4.5, use the stable Zod 4 subpath, and enable compilation on all supported SDK-owned Zod schemas. diff --git a/packages/world-postgres/src/zod.ts b/packages/world-postgres/src/zod.ts index 424e16e386..43c3a6bf70 100644 --- a/packages/world-postgres/src/zod.ts +++ b/packages/world-postgres/src/zod.ts @@ -1,10 +1,12 @@ import * as z from 'zod/v4'; -export const Base64Buffer = z.codec(z.base64(), z.instanceof(Buffer), { - decode(b64) { - return Buffer.from(b64, 'base64'); - }, - encode(buf) { - return buf.toString('base64'); - }, -}); +export const Base64Buffer = z.compile( + z.codec(z.base64(), z.instanceof(Buffer), { + decode(b64) { + return Buffer.from(b64, 'base64'); + }, + encode(buf) { + return buf.toString('base64'); + }, + }) +); diff --git a/packages/world-testing/src/server.mts b/packages/world-testing/src/server.mts index 1cb52bdc95..c89705dd5e 100644 --- a/packages/world-testing/src/server.mts +++ b/packages/world-testing/src/server.mts @@ -20,26 +20,30 @@ type Files = keyof typeof manifest.workflows; type Workflows = keyof (typeof manifest.workflows)[F]; type NonEmptyArray = [T, ...T[]]; -const Invoke = z - .object({ - file: z.enum(Object.keys(manifest.workflows) as NonEmptyArray), - workflow: z.string(), - args: z.unknown().array().default([]), - }) - .transform((obj) => { - const file = obj.file as keyof typeof manifest.workflows; - const workflow = z - .enum( - Object.keys(manifest.workflows[file]) as NonEmptyArray< - Workflows - > - ) - .parse(obj.workflow); - return { - args: obj.args, - workflow: manifest.workflows[file][workflow], - }; - }); +const Invoke = z.compile( + z + .object({ + file: z.enum(Object.keys(manifest.workflows) as NonEmptyArray), + workflow: z.string(), + args: z.unknown().array().default([]), + }) + .transform((obj) => { + const file = obj.file as keyof typeof manifest.workflows; + const workflow = z + .compile( + z.enum( + Object.keys(manifest.workflows[file]) as NonEmptyArray< + Workflows + > + ) + ) + .parse(obj.workflow); + return { + args: obj.args, + workflow: manifest.workflows[file][workflow], + }; + }) +); // Track flow handler invocations per run for testing inline execution // per-copy-ok: this file is a standalone test server entry (it calls `serve()` diff --git a/packages/world-testing/src/util.mts b/packages/world-testing/src/util.mts index 2e87dfa5e6..f18be002c8 100644 --- a/packages/world-testing/src/util.mts +++ b/packages/world-testing/src/util.mts @@ -14,12 +14,14 @@ import type { TypedHook } from 'workflow'; import * as z from 'zod/v4'; import type manifest from '../.well-known/workflow/v1/manifest.json'; -export const Control = z.object({ - state: z.literal('listening'), - info: z.object({ - port: z.number(), - }), -}); +export const Control = z.compile( + z.object({ + state: z.literal('listening'), + info: z.object({ + port: z.number(), + }), + }) +); type Control = z.infer; type Files = keyof typeof manifest.workflows; @@ -121,7 +123,7 @@ export async function startServer(opts: { throw new Error('Server did not start correctly'); } -const Invoke = z.object({ runId: z.coerce.string() }); +const Invoke = z.compile(z.object({ runId: z.coerce.string() })); export function createFetcher(control: Control) { return { diff --git a/packages/world-testing/workflows/hooks.ts b/packages/world-testing/workflows/hooks.ts index 71a9bdff73..c61fa0379f 100644 --- a/packages/world-testing/workflows/hooks.ts +++ b/packages/world-testing/workflows/hooks.ts @@ -2,11 +2,13 @@ import { defineHook, getWritable } from '@workflow/core'; import * as z from 'zod/v4'; export const Hook = defineHook({ - schema: z.object({ - data: z.string(), - done: z.boolean().optional(), - metadata: z.unknown(), - }), + schema: z.compile( + z.object({ + data: z.string(), + done: z.boolean().optional(), + metadata: z.unknown(), + }) + ), }); export async function collectWithHook(token: string, customData: string) { diff --git a/packages/world-vercel/src/events-v4.ts b/packages/world-vercel/src/events-v4.ts index 5af073e170..48607d6f9e 100644 --- a/packages/world-vercel/src/events-v4.ts +++ b/packages/world-vercel/src/events-v4.ts @@ -306,30 +306,34 @@ export interface PreconditionFailureDetails { cursor?: string; } -const CreateEventV4BodyBaseSchema = z.object({ - event: EventSchema, - run: WorkflowRunSchema.optional(), - step: StepWireSchema.transform(deserializeStep).optional(), - hook: HookSchema.optional(), - wait: WaitSchema.optional(), - stepCreated: z.literal(true).optional(), - maxEvents: z.number().int().positive().optional(), -}); - -const CreateEventV4PageSchema = z.union([ +const CreateEventV4BodyBaseSchema = z.compile( z.object({ - events: z.array(EventSchema), - cursor: z.string().nullable(), - hasMore: z.boolean(), - }), - // A response without a page omits these keys outright. Since Zod 4.4, - // `z.undefined()` no longer makes an object key implicitly optional. - z.object({ - events: z.undefined().optional(), - cursor: z.undefined().optional(), - hasMore: z.undefined().optional(), - }), -]); + event: EventSchema, + run: WorkflowRunSchema.optional(), + step: StepWireSchema.transform(deserializeStep).optional(), + hook: HookSchema.optional(), + wait: WaitSchema.optional(), + stepCreated: z.literal(true).optional(), + maxEvents: z.number().int().positive().optional(), + }) +); + +const CreateEventV4PageSchema = z.compile( + z.union([ + z.object({ + events: z.array(EventSchema), + cursor: z.string().nullable(), + hasMore: z.boolean(), + }), + // A response without a page omits these keys outright. Since Zod 4.4, + // `z.undefined()` no longer makes an object key implicitly optional. + z.object({ + events: z.undefined().optional(), + cursor: z.undefined().optional(), + hasMore: z.undefined().optional(), + }), + ]) +); const CreateEventV4BodySchema = z.compile( CreateEventV4BodyBaseSchema.and(CreateEventV4PageSchema) @@ -377,7 +381,7 @@ const CreateEventV4BodySchemas: { noop: CreateEventV4BodySchema, }; -const MaxEventsHeaderSchema = z.coerce.number().int().positive(); +const MaxEventsHeaderSchema = z.compile(z.coerce.number().int().positive()); const EventStreamEndSchema = z.compile( z.object({ _end: z.literal(1), diff --git a/packages/world-vercel/src/runs.ts b/packages/world-vercel/src/runs.ts index 3e85688967..86b150dc5b 100644 --- a/packages/world-vercel/src/runs.ts +++ b/packages/world-vercel/src/runs.ts @@ -41,16 +41,18 @@ import { * `errorCode` is a separate plaintext metadata field used for routing * and classification. */ -export const WorkflowRunWireBaseSchema = WorkflowRunBaseSchema.omit({ - error: true, - errorCode: true, -}).extend({ - error: z.union([SerializedDataSchema, z.any()]).optional(), - errorCode: z.string().optional(), - // Not part of the World interface, but passed through for direct consumers and debugging - blobStorageBytes: z.number().optional(), - streamStorageBytes: z.number().optional(), -}); +export const WorkflowRunWireBaseSchema = z.compile( + WorkflowRunBaseSchema.omit({ + error: true, + errorCode: true, + }).extend({ + error: z.union([SerializedDataSchema, z.any()]).optional(), + errorCode: z.string().optional(), + // Not part of the World interface, but passed through for direct consumers and debugging + blobStorageBytes: z.number().optional(), + streamStorageBytes: z.number().optional(), + }) +); // Wire schema for resolved data (full input/output) const WorkflowRunWireSchema = z.compile(WorkflowRunWireBaseSchema); diff --git a/packages/world/src/analytics.ts b/packages/world/src/analytics.ts index a57e25809e..a8f57e9ee0 100644 --- a/packages/world/src/analytics.ts +++ b/packages/world/src/analytics.ts @@ -25,16 +25,18 @@ const NAIVE_DATETIME = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?$/; * offset), and non-string inputs (Date, epoch number), are forwarded * without modification before coercion. */ -const UTCDateSchema = z.preprocess((value) => { - if (typeof value === 'string' && NAIVE_DATETIME.test(value)) { - return `${value.replace(' ', 'T')}Z`; - } - return value; -}, z.coerce.date()); +const UTCDateSchema = z.compile( + z.preprocess((value) => { + if (typeof value === 'string' && NAIVE_DATETIME.test(value)) { + return `${value.replace(' ', 'T')}Z`; + } + return value; + }, z.coerce.date()) +); -const NullableDateSchema = UTCDateSchema.nullable().optional(); -const NullableStringSchema = z.string().nullable().optional(); -const NullableBooleanSchema = z.boolean().nullable().optional(); +const NullableDateSchema = z.compile(UTCDateSchema.nullable().optional()); +const NullableStringSchema = z.compile(z.string().nullable().optional()); +const NullableBooleanSchema = z.compile(z.boolean().nullable().optional()); // Keep analytics object schemas standalone even when they mirror storage // metadata fields. This namespace is an explicit metadata-only read contract; diff --git a/packages/world/src/attributes.ts b/packages/world/src/attributes.ts index e6e4ee13ac..e314ef69b9 100644 --- a/packages/world/src/attributes.ts +++ b/packages/world/src/attributes.ts @@ -12,28 +12,34 @@ export * from './attributes-validation.js'; const textEncoder = new TextEncoder(); -export const AttributeKeySchema = z - .string() - .min(1, { error: 'Attribute key must not be empty' }) - .max(ATTRIBUTE_KEY_MAX_LENGTH, { - error: `Attribute key exceeds limit ${ATTRIBUTE_KEY_MAX_LENGTH}`, - }); - -export const AttributeValueSchema = z - .string() - .refine( - (value) => textEncoder.encode(value).length <= ATTRIBUTE_VALUE_MAX_BYTES, - { - error: `Attribute value exceeds limit ${ATTRIBUTE_VALUE_MAX_BYTES} UTF-8 bytes`, - } - ) - .nullable(); +export const AttributeKeySchema = z.compile( + z + .string() + .min(1, { error: 'Attribute key must not be empty' }) + .max(ATTRIBUTE_KEY_MAX_LENGTH, { + error: `Attribute key exceeds limit ${ATTRIBUTE_KEY_MAX_LENGTH}`, + }) +); + +export const AttributeValueSchema = z.compile( + z + .string() + .refine( + (value) => textEncoder.encode(value).length <= ATTRIBUTE_VALUE_MAX_BYTES, + { + error: `Attribute value exceeds limit ${ATTRIBUTE_VALUE_MAX_BYTES} UTF-8 bytes`, + } + ) + .nullable() +); /** Runtime schema for a single run-attribute change. */ -export const AttributeChangeSchema = z.object({ - key: AttributeKeySchema, - value: AttributeValueSchema, -}) satisfies z.ZodType; +export const AttributeChangeSchema = z.compile( + z.object({ + key: AttributeKeySchema, + value: AttributeValueSchema, + }) +) satisfies z.ZodType; export const AttributeChangesSchema = z.compile( z.array(AttributeChangeSchema).superRefine((changes, context) => { diff --git a/packages/world/src/events.ts b/packages/world/src/events.ts index da7e01eef3..58eeaba708 100644 --- a/packages/world/src/events.ts +++ b/packages/world/src/events.ts @@ -11,43 +11,47 @@ import type { Wait } from './waits.js'; export * from './event-metadata.js'; // Event type enum -export const EventTypeSchema = z.enum([ - // Run lifecycle events - 'run_created', - 'run_started', - 'run_completed', - 'run_failed', - 'run_cancelled', - // Run attribute events - 'attr_set', - // Step lifecycle events - 'step_created', - 'step_completed', - 'step_failed', - 'step_retrying', - 'step_started', - // Hook lifecycle events - 'hook_created', - 'hook_received', - 'hook_disposed', - 'hook_conflict', // Created by world when hook token already exists - // Wait lifecycle events - 'wait_created', - 'wait_completed', - // Sealed-log filler (specVersion >= 7): written ONLY by the World's backend - // to occupy a slot whose writer allocated it and died. Carries no workflow - // meaning; replay skips it (see EventsConsumer). Never user-creatable. - 'noop', -]); +export const EventTypeSchema = z.compile( + z.enum([ + // Run lifecycle events + 'run_created', + 'run_started', + 'run_completed', + 'run_failed', + 'run_cancelled', + // Run attribute events + 'attr_set', + // Step lifecycle events + 'step_created', + 'step_completed', + 'step_failed', + 'step_retrying', + 'step_started', + // Hook lifecycle events + 'hook_created', + 'hook_received', + 'hook_disposed', + 'hook_conflict', // Created by world when hook token already exists + // Wait lifecycle events + 'wait_created', + 'wait_completed', + // Sealed-log filler (specVersion >= 7): written ONLY by the World's backend + // to occupy a slot whose writer allocated it and died. Carries no workflow + // meaning; replay skips it (see EventsConsumer). Never user-creatable. + 'noop', + ]) +); export type EventType = z.infer; -const RunEventTypeSchema = EventTypeSchema.extract([ - 'run_created', - 'run_started', - 'run_completed', - 'run_failed', - 'run_cancelled', -] as const); +const RunEventTypeSchema = z.compile( + EventTypeSchema.extract([ + 'run_created', + 'run_started', + 'run_completed', + 'run_failed', + 'run_cancelled', + ] as const) +); export type RunEventType = z.infer; export const RUN_EVENT_TYPES = RunEventTypeSchema.options; @@ -55,11 +59,13 @@ export function isRunEventType(eventType: string): eventType is RunEventType { return RUN_EVENT_TYPES.includes(eventType as RunEventType); } -export const TerminalRunEventTypeSchema = EventTypeSchema.extract([ - 'run_completed', - 'run_failed', - 'run_cancelled', -] as const); +export const TerminalRunEventTypeSchema = z.compile( + EventTypeSchema.extract([ + 'run_completed', + 'run_failed', + 'run_cancelled', + ] as const) +); export type TerminalRunEventType = z.infer; export const TERMINAL_RUN_EVENT_TYPES = TerminalRunEventTypeSchema.options; @@ -69,13 +75,15 @@ export function isTerminalRunEventType( return TERMINAL_RUN_EVENT_TYPES.includes(eventType as TerminalRunEventType); } -const StepEventTypeSchema = EventTypeSchema.extract([ - 'step_created', - 'step_completed', - 'step_failed', - 'step_retrying', - 'step_started', -] as const); +const StepEventTypeSchema = z.compile( + EventTypeSchema.extract([ + 'step_created', + 'step_completed', + 'step_failed', + 'step_retrying', + 'step_started', + ] as const) +); export type StepEventType = z.infer; export const STEP_EVENT_TYPES = StepEventTypeSchema.options; @@ -83,10 +91,9 @@ export function isStepEventType(eventType: string): eventType is StepEventType { return STEP_EVENT_TYPES.includes(eventType as StepEventType); } -const TerminalStepEventTypeSchema = EventTypeSchema.extract([ - 'step_completed', - 'step_failed', -] as const); +const TerminalStepEventTypeSchema = z.compile( + EventTypeSchema.extract(['step_completed', 'step_failed'] as const) +); export type TerminalStepEventType = z.infer; export const TERMINAL_STEP_EVENT_TYPES = TerminalStepEventTypeSchema.options; @@ -96,11 +103,13 @@ export function isTerminalStepEventType( return TERMINAL_STEP_EVENT_TYPES.includes(eventType as TerminalStepEventType); } -const HookLifecycleEventTypeSchema = EventTypeSchema.extract([ - 'hook_created', - 'hook_received', - 'hook_disposed', -] as const); +const HookLifecycleEventTypeSchema = z.compile( + EventTypeSchema.extract([ + 'hook_created', + 'hook_received', + 'hook_disposed', + ] as const) +); export type HookLifecycleEventType = z.infer< typeof HookLifecycleEventTypeSchema >; @@ -114,10 +123,9 @@ export function isHookLifecycleEventType( ); } -const HookEventRequiringExistenceTypeSchema = EventTypeSchema.extract([ - 'hook_disposed', - 'hook_received', -] as const); +const HookEventRequiringExistenceTypeSchema = z.compile( + EventTypeSchema.extract(['hook_disposed', 'hook_received'] as const) +); export type HookEventRequiringExistenceType = z.infer< typeof HookEventRequiringExistenceTypeSchema >; @@ -132,10 +140,9 @@ export function isHookEventRequiringExistence( ); } -const WaitEventTypeSchema = EventTypeSchema.extract([ - 'wait_created', - 'wait_completed', -] as const); +const WaitEventTypeSchema = z.compile( + EventTypeSchema.extract(['wait_created', 'wait_completed'] as const) +); export type WaitEventType = z.infer; export const WAIT_EVENT_TYPES = WaitEventTypeSchema.options; @@ -143,11 +150,13 @@ export function isWaitEventType(eventType: string): eventType is WaitEventType { return WAIT_EVENT_TYPES.includes(eventType as WaitEventType); } -const ChildEntityCreationEventTypeSchema = EventTypeSchema.extract([ - 'step_created', - 'hook_created', - 'wait_created', -] as const); +const ChildEntityCreationEventTypeSchema = z.compile( + EventTypeSchema.extract([ + 'step_created', + 'hook_created', + 'wait_created', + ] as const) +); export type ChildEntityCreationEventType = z.infer< typeof ChildEntityCreationEventTypeSchema >; @@ -201,11 +210,13 @@ export function stripEventDataRefs( // Changing the type here will mainly improve type safety for o11y consumers. // Note: specVersion is optional for backward compatibility with legacy data in storage, // but is always sent by the runtime on new events. -export const BaseEventSchema = z.object({ - eventType: EventTypeSchema, - correlationId: z.string().optional(), - specVersion: z.number().optional(), -}); +export const BaseEventSchema = z.compile( + z.object({ + eventType: EventTypeSchema, + correlationId: z.string().optional(), + specVersion: z.number().optional(), + }) +); // Event schemas (shared between creation requests and server responses) // Note: Serialized data fields use SerializedDataSchema to support both: @@ -249,48 +260,54 @@ const stepLatencyTelemetryFields = { optimizations: z.array(z.string()).optional(), }; -const StepCompletedEventSchema = BaseEventSchema.extend({ - eventType: z.literal('step_completed'), - correlationId: z.string(), - eventData: z.object({ - stepName: z.string().optional(), - // Carried so a backend that keys payload refs by workflow name can build - // the key without an extra run lookup on this hot per-step write. - // Optional: older runtimes omit it and the backend falls back to a read. - workflowName: z.string().optional(), - result: SerializedDataSchema, - ...stepLatencyTelemetryFields, - }), -}); +const StepCompletedEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('step_completed'), + correlationId: z.string(), + eventData: z.object({ + stepName: z.string().optional(), + // Carried so a backend that keys payload refs by workflow name can build + // the key without an extra run lookup on this hot per-step write. + // Optional: older runtimes omit it and the backend falls back to a read. + workflowName: z.string().optional(), + result: SerializedDataSchema, + ...stepLatencyTelemetryFields, + }), + }) +); -const StepFailedEventSchema = BaseEventSchema.extend({ - eventType: z.literal('step_failed'), - correlationId: z.string(), - eventData: z.object({ - stepName: z.string().optional(), - // The thrown value, serialized via the workflow serialization pipeline. - // Can be any JavaScript value (string, number, object, Error, etc.) - error: SerializedDataSchema, - ...stepLatencyTelemetryFields, - }), -}); +const StepFailedEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('step_failed'), + correlationId: z.string(), + eventData: z.object({ + stepName: z.string().optional(), + // The thrown value, serialized via the workflow serialization pipeline. + // Can be any JavaScript value (string, number, object, Error, etc.) + error: SerializedDataSchema, + ...stepLatencyTelemetryFields, + }), + }) +); /** * Event created when a step fails and will be retried. * Sets the step status back to 'pending' and records the error. * The error is stored in step.error for debugging. */ -const StepRetryingEventSchema = BaseEventSchema.extend({ - eventType: z.literal('step_retrying'), - correlationId: z.string(), - eventData: z.object({ - stepName: z.string().optional(), - // The thrown value, serialized via the workflow serialization pipeline. - // Can be any JavaScript value (string, number, object, Error, etc.) - error: SerializedDataSchema, - retryAfter: z.coerce.date().optional(), - }), -}); +const StepRetryingEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('step_retrying'), + correlationId: z.string(), + eventData: z.object({ + stepName: z.string().optional(), + // The thrown value, serialized via the workflow serialization pipeline. + // Can be any JavaScript value (string, number, object, Error, etc.) + error: SerializedDataSchema, + retryAfter: z.coerce.date().optional(), + }), + }) +); /** * Event created when a step begins executing. @@ -305,84 +322,94 @@ const StepRetryingEventSchema = BaseEventSchema.extend({ * it. This mirrors the resilient `run_started` start path above. When `input` * is absent the World requires a prior `step_created` (the legacy contract). */ -const StepStartedEventSchema = BaseEventSchema.extend({ - eventType: z.literal('step_started'), - correlationId: z.string(), - eventData: z - .object({ - stepName: z.string().optional(), - attempt: z.number().optional(), - // Carried on the lazy-start path (where `input` is present) so the - // backend can build the payload ref key without re-reading the run. - workflowName: z.string().optional(), - // Lazy-start: the dehydrated step input, present only when this - // step_started is also responsible for creating the step. - input: SerializedDataSchema.optional(), - // Inline step ownership: the queue message ID of the invocation whose - // handler is executing this step's body inline. Stamped on the lazy - // step_started (and re-stamped on an owner-recovery bare start) so - // that a wake replay can tell "this attempt is in flight in a live - // invocation" apart from "this attempt died with its process": the - // owner's queue message doubles as the liveness lease (a crash means - // the queue redelivers that same messageId, which is allowed to - // re-execute). Ownership derives from the step's LATEST step_started: - // an unstamped bare start (a retry attempt driven by a queued step - // message) clears it. Absent on eager steps and from older runtimes. - // Requires the queue's messageId to be stable across redeliveries of - // one message (see the Queue.createQueueHandler meta contract). - ownerMessageId: z.string().optional(), - }) - .optional(), -}); +const StepStartedEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('step_started'), + correlationId: z.string(), + eventData: z + .object({ + stepName: z.string().optional(), + attempt: z.number().optional(), + // Carried on the lazy-start path (where `input` is present) so the + // backend can build the payload ref key without re-reading the run. + workflowName: z.string().optional(), + // Lazy-start: the dehydrated step input, present only when this + // step_started is also responsible for creating the step. + input: SerializedDataSchema.optional(), + // Inline step ownership: the queue message ID of the invocation whose + // handler is executing this step's body inline. Stamped on the lazy + // step_started (and re-stamped on an owner-recovery bare start) so + // that a wake replay can tell "this attempt is in flight in a live + // invocation" apart from "this attempt died with its process": the + // owner's queue message doubles as the liveness lease (a crash means + // the queue redelivers that same messageId, which is allowed to + // re-execute). Ownership derives from the step's LATEST step_started: + // an unstamped bare start (a retry attempt driven by a queued step + // message) clears it. Absent on eager steps and from older runtimes. + // Requires the queue's messageId to be stable across redeliveries of + // one message (see the Queue.createQueueHandler meta contract). + ownerMessageId: z.string().optional(), + }) + .optional(), + }) +); /** * Event created when a step is first invoked. The World implementation * atomically creates both the event and the step entity. */ -const StepCreatedEventSchema = BaseEventSchema.extend({ - eventType: z.literal('step_created'), - correlationId: z.string(), - eventData: z.object({ - stepName: z.string(), - workflowName: z.string().optional(), - input: SerializedDataSchema, - }), -}); +const StepCreatedEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('step_created'), + correlationId: z.string(), + eventData: z.object({ + stepName: z.string(), + workflowName: z.string().optional(), + input: SerializedDataSchema, + }), + }) +); /** * Event created when a hook is first invoked. The World implementation * atomically creates both the event and the hook entity. */ -export const HookCreatedEventSchema = BaseEventSchema.extend({ - eventType: z.literal('hook_created'), - correlationId: z.string(), - eventData: z.object({ - token: z.string(), - tokenRetentionUntil: z.coerce.date().optional(), - metadata: SerializedDataSchema.optional(), - isWebhook: z.boolean().optional(), - isSystem: z.boolean().optional(), - }), -}); - -const HookReceivedEventSchema = BaseEventSchema.extend({ - eventType: z.literal('hook_received'), - correlationId: z.string(), - eventData: z.object({ - token: z.string().optional(), - payload: SerializedDataSchema, - }), -}); +export const HookCreatedEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('hook_created'), + correlationId: z.string(), + eventData: z.object({ + token: z.string(), + tokenRetentionUntil: z.coerce.date().optional(), + metadata: SerializedDataSchema.optional(), + isWebhook: z.boolean().optional(), + isSystem: z.boolean().optional(), + }), + }) +); -const HookDisposedEventSchema = BaseEventSchema.extend({ - eventType: z.literal('hook_disposed'), - correlationId: z.string(), - eventData: z - .object({ +const HookReceivedEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('hook_received'), + correlationId: z.string(), + eventData: z.object({ token: z.string().optional(), - }) - .optional(), -}); + payload: SerializedDataSchema, + }), + }) +); + +const HookDisposedEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('hook_disposed'), + correlationId: z.string(), + eventData: z + .object({ + token: z.string().optional(), + }) + .optional(), + }) +); /** * Event created by World implementations when a hook_created request @@ -392,16 +419,18 @@ const HookDisposedEventSchema = BaseEventSchema.extend({ * When the hook consumer sees this event, it should reject any awaited * promises with a HookTokenConflictError. */ -const HookConflictEventSchema = BaseEventSchema.extend({ - eventType: z.literal('hook_conflict'), - correlationId: z.string(), - eventData: z.object({ - token: z.string(), - // TODO: Make this required once all persisted hook_conflict events and - // remote World implementations always include the active hook owner's run ID. - conflictingRunId: z.string().optional(), - }), -}); +const HookConflictEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('hook_conflict'), + correlationId: z.string(), + eventData: z.object({ + token: z.string(), + // TODO: Make this required once all persisted hook_conflict events and + // remote World implementations always include the active hook owner's run ID. + conflictingRunId: z.string().optional(), + }), + }) +); /** * Sealed-log filler event (specVersion >= 7). Written ONLY by the World's @@ -412,58 +441,68 @@ const HookConflictEventSchema = BaseEventSchema.extend({ * without advancing the deterministic clock. NOT user-creatable, and absent * from `CreateEventSchema` for that reason. */ -const NoopEventSchema = BaseEventSchema.extend({ - eventType: z.literal('noop'), - eventData: z - .object({ - sealed: z.boolean().optional(), - }) - .passthrough() - .optional(), -}); +const NoopEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('noop'), + eventData: z + .object({ + sealed: z.boolean().optional(), + }) + .passthrough() + .optional(), + }) +); -const WaitCreatedEventSchema = BaseEventSchema.extend({ - eventType: z.literal('wait_created'), - correlationId: z.string(), - eventData: z.object({ - resumeAt: z.coerce.date(), - }), -}); +const WaitCreatedEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('wait_created'), + correlationId: z.string(), + eventData: z.object({ + resumeAt: z.coerce.date(), + }), + }) +); -const WaitCompletedEventSchema = BaseEventSchema.extend({ - eventType: z.literal('wait_completed'), - correlationId: z.string(), - eventData: z - .object({ - resumeAt: z.coerce.date().optional(), - }) - .optional(), -}); +const WaitCompletedEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('wait_completed'), + correlationId: z.string(), + eventData: z + .object({ + resumeAt: z.coerce.date().optional(), + }) + .optional(), + }) +); -const AttributeWriterSchema = z.discriminatedUnion('type', [ - z.object({ - type: z.literal('workflow'), - }), - z.object({ - type: z.literal('step'), - stepId: z.string(), - attempt: z.number(), - }), -]); +const AttributeWriterSchema = z.compile( + z.discriminatedUnion('type', [ + z.object({ + type: z.literal('workflow'), + }), + z.object({ + type: z.literal('step'), + stepId: z.string(), + attempt: z.number(), + }), + ]) +); /** * Event created when workflow or step code changes the run's plaintext * attributes. The World materializes changes into `run.attributes`. */ -const AttrSetEventSchema = BaseEventSchema.extend({ - eventType: z.literal('attr_set'), - correlationId: z.string().optional(), - eventData: z.object({ - changes: AttributeChangesSchema, - writer: AttributeWriterSchema, - allowReservedAttributes: z.literal(true).optional(), - }), -}); +const AttrSetEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('attr_set'), + correlationId: z.string().optional(), + eventData: z.object({ + changes: AttributeChangesSchema, + writer: AttributeWriterSchema, + allowReservedAttributes: z.literal(true).optional(), + }), + }) +); // ============================================================================= // Run lifecycle events @@ -473,24 +512,26 @@ const AttrSetEventSchema = BaseEventSchema.extend({ * Event created when a workflow run is first created. The World implementation * atomically creates both the event and the run entity with status 'pending'. */ -const RunCreatedEventSchema = BaseEventSchema.extend({ - eventType: z.literal('run_created'), - eventData: z.object({ - deploymentId: z.string(), - workflowName: z.string(), - input: SerializedDataSchema, - executionContext: z.record(z.string(), z.any()).optional(), - attributes: z.record(z.string(), z.string()).optional(), - allowReservedAttributes: z.literal(true).optional(), - /** - * The run's X25519 public key (base64), stamped by SDKs that support - * sealed (`encp`) envelopes. Persisted onto the run entity so that - * cross-run writers can seal payloads to this run without holding its - * symmetric key. Not secret. See `WorkflowRunBaseSchema`. - */ - encryptionPublicKey: z.string().optional(), - }), -}); +const RunCreatedEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('run_created'), + eventData: z.object({ + deploymentId: z.string(), + workflowName: z.string(), + input: SerializedDataSchema, + executionContext: z.record(z.string(), z.any()).optional(), + attributes: z.record(z.string(), z.string()).optional(), + allowReservedAttributes: z.literal(true).optional(), + /** + * The run's X25519 public key (base64), stamped by SDKs that support + * sealed (`encp`) envelopes. Persisted onto the run entity so that + * cross-run writers can seal payloads to this run without holding its + * symmetric key. Not secret. See `WorkflowRunBaseSchema`. + */ + encryptionPublicKey: z.string().optional(), + }), + }) +); /** * Event created when a workflow run starts executing. @@ -501,71 +542,79 @@ const RunCreatedEventSchema = BaseEventSchema.extend({ * runtime passes the run input through the queue so the server can create the run * on the run_started call if it doesn't exist yet. */ -const RunStartedEventSchema = BaseEventSchema.extend({ - eventType: z.literal('run_started'), - eventData: z - .object({ - input: SerializedDataSchema.optional(), - deploymentId: z.string().optional(), - workflowName: z.string().optional(), - executionContext: z.record(z.string(), z.any()).optional(), - attributes: z.record(z.string(), z.string()).optional(), - allowReservedAttributes: z.literal(true).optional(), - /** - * Mirrors `run_created.eventData.encryptionPublicKey`. Carried here for - * the resilient-start path: when the `run_created` write failed, the - * server creates the run from this event instead, and without the key - * the run would silently lose its ability to receive sealed writes. - */ - encryptionPublicKey: z.string().optional(), - }) - .optional(), -}); +const RunStartedEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('run_started'), + eventData: z + .object({ + input: SerializedDataSchema.optional(), + deploymentId: z.string().optional(), + workflowName: z.string().optional(), + executionContext: z.record(z.string(), z.any()).optional(), + attributes: z.record(z.string(), z.string()).optional(), + allowReservedAttributes: z.literal(true).optional(), + /** + * Mirrors `run_created.eventData.encryptionPublicKey`. Carried here for + * the resilient-start path: when the `run_created` write failed, the + * server creates the run from this event instead, and without the key + * the run would silently lose its ability to receive sealed writes. + */ + encryptionPublicKey: z.string().optional(), + }) + .optional(), + }) +); /** * Event created when a workflow run completes successfully. * Updates the run entity to status 'completed' with output. */ -const RunCompletedEventSchema = BaseEventSchema.extend({ - eventType: z.literal('run_completed'), - eventData: z.object({ - output: SerializedDataSchema.optional(), - }), -}); +const RunCompletedEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('run_completed'), + eventData: z.object({ + output: SerializedDataSchema.optional(), + }), + }) +); /** * Event created when a workflow run fails. * Updates the run entity to status 'failed' with error. */ -const RunFailedEventSchema = BaseEventSchema.extend({ - eventType: z.literal('run_failed'), - eventData: z.object({ - // The thrown value, serialized via the workflow serialization pipeline. - // Can be any JavaScript value (string, number, object, Error, etc.) - error: SerializedDataSchema, - // The high-level error category (USER_ERROR, RUNTIME_ERROR, etc.) used - // for routing and classification. Kept as plaintext metadata so - // observability tools can filter/categorize without needing to decrypt - // the full error payload. - errorCode: z.string().optional(), - }), -}); +const RunFailedEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('run_failed'), + eventData: z.object({ + // The thrown value, serialized via the workflow serialization pipeline. + // Can be any JavaScript value (string, number, object, Error, etc.) + error: SerializedDataSchema, + // The high-level error category (USER_ERROR, RUNTIME_ERROR, etc.) used + // for routing and classification. Kept as plaintext metadata so + // observability tools can filter/categorize without needing to decrypt + // the full error payload. + errorCode: z.string().optional(), + }), + }) +); /** * Event created when a workflow run is cancelled. * Updates the run entity to status 'cancelled'. */ -const RunCancelledEventSchema = BaseEventSchema.extend({ - eventType: z.literal('run_cancelled'), - eventData: z - .object({ - // Optional free-text reason for the cancellation. Kept as small - // plaintext metadata (like run_failed's errorCode) so it survives - // resolveData: 'none' and can be displayed without decryption. - cancelReason: z.string().max(512).optional(), - }) - .optional(), -}); +const RunCancelledEventSchema = z.compile( + BaseEventSchema.extend({ + eventType: z.literal('run_cancelled'), + eventData: z + .object({ + // Optional free-text reason for the cancellation. Kept as small + // plaintext metadata (like run_failed's errorCode) so it survives + // resolveData: 'none' and can be displayed without decryption. + cancelReason: z.string().max(512).optional(), + }) + .optional(), + }) +); // Discriminated union for user-creatable events (requests to world.events.create) // Note: hook_conflict is NOT included here - it can only be created by World implementations @@ -596,30 +645,32 @@ export const CreateEventSchema = z.compile( // Discriminated union for ALL events (includes World-only events like hook_conflict) // This is used for reading events from the event log -const AllEventsSchema = z.discriminatedUnion('eventType', [ - // Run lifecycle events - RunCreatedEventSchema, - RunStartedEventSchema, - RunCompletedEventSchema, - RunFailedEventSchema, - RunCancelledEventSchema, - AttrSetEventSchema, - // Step lifecycle events - StepCreatedEventSchema, - StepCompletedEventSchema, - StepFailedEventSchema, - StepRetryingEventSchema, - StepStartedEventSchema, - // Hook lifecycle events - HookCreatedEventSchema, - HookReceivedEventSchema, - HookDisposedEventSchema, - HookConflictEventSchema, // World-only: created when hook token conflicts - // Wait lifecycle events - WaitCreatedEventSchema, - WaitCompletedEventSchema, - NoopEventSchema, // World-only: sealed-log filler for an abandoned slot -]); +const AllEventsSchema = z.compile( + z.discriminatedUnion('eventType', [ + // Run lifecycle events + RunCreatedEventSchema, + RunStartedEventSchema, + RunCompletedEventSchema, + RunFailedEventSchema, + RunCancelledEventSchema, + AttrSetEventSchema, + // Step lifecycle events + StepCreatedEventSchema, + StepCompletedEventSchema, + StepFailedEventSchema, + StepRetryingEventSchema, + StepStartedEventSchema, + // Hook lifecycle events + HookCreatedEventSchema, + HookReceivedEventSchema, + HookDisposedEventSchema, + HookConflictEventSchema, // World-only: created when hook token conflicts + // Wait lifecycle events + WaitCreatedEventSchema, + WaitCompletedEventSchema, + NoopEventSchema, // World-only: sealed-log filler for an abandoned slot + ]) +); // Server response includes runId, eventId, and createdAt // specVersion is optional in database for backward compatibility diff --git a/packages/world/src/hooks.ts b/packages/world/src/hooks.ts index 9f3913c2a4..4f6e09e077 100644 --- a/packages/world/src/hooks.ts +++ b/packages/world/src/hooks.ts @@ -14,29 +14,31 @@ import type { PaginationOptions, ResolveData } from './shared.js'; * excludes the run's mutable state (e.g. status), inputs/outputs, attributes, * and any secret: only fields that are fixed at hook-creation time. */ -export const HookResumeContextSchema = z.object({ - deploymentId: z.string(), - workflowName: z.string(), - // Named `runSpecVersion` to distinguish it from the hook's own `specVersion`. - runSpecVersion: z.number().optional(), - workflowCoreVersion: z.string().optional(), - traceCarrier: TraceCarrierSchema.optional(), - // The run's published X25519 public key (base64), mirrored from the run - // entity. Lets a resume seal (`encp`) its payload to the run without reading - // the run or fetching its symmetric key. Absent on runs created before - // sealed envelopes and on projects with encryption disabled, where the - // resume falls back to the symmetric per-run key. - encryptionPublicKey: z.string().optional(), - // Feature marker: the version of the lazy-hook-resume consumer protocol the - // run's creating deployment supports. Present (>= 1) means that deployment's - // `@workflow/core` re-ensures the `hook_received` event from the queue - // message's `hookInput` on replay, so `resumeHook()`'s lazy path is safe to - // use. Because a run is pinned to its creating deployment, this - // marker is a reliable per-run attestation, unlike inferring support from a - // version compare against a predicted release cutoff. Absent on runs created - // before the marker existed (fall back to the sequential path). - hookResumeInputVersion: z.number().optional(), -}); +export const HookResumeContextSchema = z.compile( + z.object({ + deploymentId: z.string(), + workflowName: z.string(), + // Named `runSpecVersion` to distinguish it from the hook's own `specVersion`. + runSpecVersion: z.number().optional(), + workflowCoreVersion: z.string().optional(), + traceCarrier: TraceCarrierSchema.optional(), + // The run's published X25519 public key (base64), mirrored from the run + // entity. Lets a resume seal (`encp`) its payload to the run without reading + // the run or fetching its symmetric key. Absent on runs created before + // sealed envelopes and on projects with encryption disabled, where the + // resume falls back to the symmetric per-run key. + encryptionPublicKey: z.string().optional(), + // Feature marker: the version of the lazy-hook-resume consumer protocol the + // run's creating deployment supports. Present (>= 1) means that deployment's + // `@workflow/core` re-ensures the `hook_received` event from the queue + // message's `hookInput` on replay, so `resumeHook()`'s lazy path is safe to + // use. Because a run is pinned to its creating deployment, this + // marker is a reliable per-run attestation, unlike inferring support from a + // version compare against a predicted release cutoff. Absent on runs created + // before the marker existed (fall back to the sequential path). + hookResumeInputVersion: z.number().optional(), + }) +); export type HookResumeContext = z.infer; @@ -72,13 +74,15 @@ export const HOOK_RESUME_DEDUP_VERSION = 1; * `hookResumeInputVersion`, which attests the *consumer* and is fixed at run * creation.) */ -export const HookResumeCapabilitiesSchema = z.object({ - // Present (>= HOOK_RESUME_DEDUP_VERSION) when the live backend enforces the - // `(runId, resumeId)` dedup constraint AND no server-side kill switch is - // active. Absent against an older/rolled-back server or when the kill switch - // is engaged. - hookResumeDedupVersion: z.number(), -}); +export const HookResumeCapabilitiesSchema = z.compile( + z.object({ + // Present (>= HOOK_RESUME_DEDUP_VERSION) when the live backend enforces the + // `(runId, resumeId)` dedup constraint AND no server-side kill switch is + // active. Absent against an older/rolled-back server or when the kill switch + // is engaged. + hookResumeDedupVersion: z.number(), + }) +); export type HookResumeCapabilities = z.infer< typeof HookResumeCapabilitiesSchema diff --git a/packages/world/src/queue.ts b/packages/world/src/queue.ts index 60508d805d..a9067b3a98 100644 --- a/packages/world/src/queue.ts +++ b/packages/world/src/queue.ts @@ -9,28 +9,34 @@ export type QueueKind = 'workflow'; * * Namespace must be lowercase alphanumeric starting with a letter. */ -export const QueuePrefix = z - .string() - .regex( - /^__(?:[a-z][a-z0-9]*_)?wkf_workflow_$/, - 'Must match __wkf_workflow_ or __{namespace}_wkf_workflow_' - ); +export const QueuePrefix = z.compile( + z + .string() + .regex( + /^__(?:[a-z][a-z0-9]*_)?wkf_workflow_$/, + 'Must match __wkf_workflow_ or __{namespace}_wkf_workflow_' + ) +); export type QueuePrefix = z.infer; -export const ValidQueueName = z - .string() - .regex( - /^__(?:[a-z][a-z0-9]*_)?wkf_workflow_.+$/, - 'Must be a valid queue name with a recognized prefix' - ); +export const ValidQueueName = z.compile( + z + .string() + .regex( + /^__(?:[a-z][a-z0-9]*_)?wkf_workflow_.+$/, + 'Must be a valid queue name with a recognized prefix' + ) +); export type ValidQueueName = z.infer; -const QueueNamespace = z - .string() - .regex( - /^[a-z][a-z0-9]*$/, - 'Must be lowercase alphanumeric, starting with a letter' - ); +const QueueNamespace = z.compile( + z + .string() + .regex( + /^[a-z][a-z0-9]*$/, + 'Must be lowercase alphanumeric, starting with a letter' + ) +); /** * Resolves the active queue namespace from an explicit argument or the @@ -79,16 +85,15 @@ export function parseQueueName(name: ValidQueueName): { }; } -export const MessageId = z - .string() - .brand<'MessageId'>() - .describe('A stored queue message ID'); +export const MessageId = z.compile( + z.string().brand<'MessageId'>().describe('A stored queue message ID') +); export type MessageId = z.infer; /** * OpenTelemetry trace context for distributed tracing */ -export const TraceCarrierSchema = z.record(z.string(), z.string()); +export const TraceCarrierSchema = z.compile(z.record(z.string(), z.string())); export type TraceCarrier = z.infer; /** @@ -97,41 +102,43 @@ export type TraceCarrier = z.infer; * When the runtime processes the message, it passes this data to the * run_started event so the server can create the run if it doesn't exist yet. */ -export const RunInputSchema = z.object({ - input: z.unknown(), - deploymentId: z.string(), - workflowName: z.string(), - specVersion: z.number(), - executionContext: z.record(z.string(), z.any()).optional(), - /** Initial plaintext run attributes, for resilient run creation. */ - attributes: z.record(z.string(), z.string()).optional(), - /** - * Permits reserved `$`-prefixed keys in `attributes`, mirrored from the - * `start()` option so resilient run creation validates the same way as - * the original `run_created` attempt. - */ - allowReservedAttributes: z.literal(true).optional(), - /** - * The environment the creating client's writes are attributed to, as - * reported by {@link World.getEnvironment} at `start()` time (on Vercel: - * `'production' | 'preview' | 'development'`). - * - * This exists so the resilient-start path can be checked for a tenant - * mismatch. `start()` writes `run_created` under the caller's own - * credentials while pinning the queue message to a deployment, and those - * two can disagree: if the message is consumed by a deployment in a - * DIFFERENT environment, that consumer's `run_started` re-creates the run - * under ITS tenant, so the same client-minted `wrun_` id ends up existing - * in two environments: one stuck pending forever, the other executing. - * Carrying the creator's environment lets the consumer compare it against - * its own and refuse the delivery instead of forking the run. - * - * Absent for worlds with no environment dimension (local, Postgres), and - * for older SDKs. Consumers must treat it as advisory and skip the check - * when it is missing. - */ - environment: z.string().optional(), -}); +export const RunInputSchema = z.compile( + z.object({ + input: z.unknown(), + deploymentId: z.string(), + workflowName: z.string(), + specVersion: z.number(), + executionContext: z.record(z.string(), z.any()).optional(), + /** Initial plaintext run attributes, for resilient run creation. */ + attributes: z.record(z.string(), z.string()).optional(), + /** + * Permits reserved `$`-prefixed keys in `attributes`, mirrored from the + * `start()` option so resilient run creation validates the same way as + * the original `run_created` attempt. + */ + allowReservedAttributes: z.literal(true).optional(), + /** + * The environment the creating client's writes are attributed to, as + * reported by {@link World.getEnvironment} at `start()` time (on Vercel: + * `'production' | 'preview' | 'development'`). + * + * This exists so the resilient-start path can be checked for a tenant + * mismatch. `start()` writes `run_created` under the caller's own + * credentials while pinning the queue message to a deployment, and those + * two can disagree: if the message is consumed by a deployment in a + * DIFFERENT environment, that consumer's `run_started` re-creates the run + * under ITS tenant, so the same client-minted `wrun_` id ends up existing + * in two environments: one stuck pending forever, the other executing. + * Carrying the creator's environment lets the consumer compare it against + * its own and refuse the delivery instead of forking the run. + * + * Absent for worlds with no environment dimension (local, Postgres), and + * for older SDKs. Consumers must treat it as advisory and skip the check + * when it is missing. + */ + environment: z.string().optional(), + }) +); export type RunInput = z.infer; /** @@ -166,54 +173,58 @@ export type RunInput = z.infer; * The `input` is the already-serialized (and possibly encrypted) step input: * the identical bytes the producer also sent on the direct `events.create`. */ -export const StepDispatchInputSchema = z.object({ - /** - * The serialized step input, reused verbatim from the direct write. Always - * binary: producers only attach `stepInput` when the dehydrated input is a - * `Uint8Array` and the run's queue transport preserves bytes (CBOR). - * Validated here so a malformed or transport-mangled payload fails the - * message parse instead of being silently written into a `step_created` as - * non-binary data. `Buffer` is a `Uint8Array` subclass and passes. - */ - input: z.custom((value) => value instanceof Uint8Array, { - message: 'stepInput.input must be a Uint8Array', - }), -}); +export const StepDispatchInputSchema = z.compile( + z.object({ + /** + * The serialized step input, reused verbatim from the direct write. Always + * binary: producers only attach `stepInput` when the dehydrated input is a + * `Uint8Array` and the run's queue transport preserves bytes (CBOR). + * Validated here so a malformed or transport-mangled payload fails the + * message parse instead of being silently written into a `step_created` as + * non-binary data. `Buffer` is a `Uint8Array` subclass and passes. + */ + input: z.custom((value) => value instanceof Uint8Array, { + message: 'stepInput.input must be a Uint8Array', + }), + }) +); export type StepDispatchInput = z.infer; -export const HookResumeInputSchema = z.object({ - /** Stable idempotency key minted once per `resumeHook()` call. */ - resumeId: z.string(), - /** The hook being resumed. */ - hookId: z.string(), - /** - * The hook's token, written into the `hook_received` event's `eventData` so - * the consumer's re-ensured event carries the same token the producer - * would. Replay validates `eventData.token` against the `createHook` token. - */ - token: z.string(), - /** The serialized resume payload, reused verbatim from the direct write. */ - payload: z.unknown(), - /** - * Content digest of `payload`, computed once by the producer over the - * serialized bytes and forwarded verbatim on both the direct `events.create` - * and this queue message. The consumer forwards it back to the server so both - * writers of the same `resumeId` record an identical digest on the - * `(runId, resumeId)` constraint, required because the v4 payload ref is not - * content-stable server-side. - */ - payloadDigest: z.string(), - /** - * The deployment the run is pinned to, from the producer's resume context. - * Lets the consumer detect a misrouted delivery with a cheap ambient - * deployment-id comparison BEFORE its hoisted `hook_received` replay-preload - * write: only a detected mismatch pays for the authoritative run fetch and - * the deployment-affinity guard. Optional for queued-message compatibility: - * messages from older producers omit it and skip the pre-write - * check (the authoritative guard before replay still protects them). - */ - deploymentId: z.string().optional(), -}); +export const HookResumeInputSchema = z.compile( + z.object({ + /** Stable idempotency key minted once per `resumeHook()` call. */ + resumeId: z.string(), + /** The hook being resumed. */ + hookId: z.string(), + /** + * The hook's token, written into the `hook_received` event's `eventData` so + * the consumer's re-ensured event carries the same token the producer + * would. Replay validates `eventData.token` against the `createHook` token. + */ + token: z.string(), + /** The serialized resume payload, reused verbatim from the direct write. */ + payload: z.unknown(), + /** + * Content digest of `payload`, computed once by the producer over the + * serialized bytes and forwarded verbatim on both the direct `events.create` + * and this queue message. The consumer forwards it back to the server so both + * writers of the same `resumeId` record an identical digest on the + * `(runId, resumeId)` constraint, required because the v4 payload ref is not + * content-stable server-side. + */ + payloadDigest: z.string(), + /** + * The deployment the run is pinned to, from the producer's resume context. + * Lets the consumer detect a misrouted delivery with a cheap ambient + * deployment-id comparison BEFORE its hoisted `hook_received` replay-preload + * write: only a detected mismatch pays for the authoritative run fetch and + * the deployment-affinity guard. Optional for queued-message compatibility: + * messages from older producers omit it and skip the pre-write + * check (the authoritative guard before replay still protects them). + */ + deploymentId: z.string().optional(), + }) +); export type HookResumeInput = z.infer; /** @@ -245,25 +256,27 @@ export type HookResumeInput = z.infer; * parse of the whole invocation payload (which would wedge the run), since it * is only ever forwarded to a span attribute. */ -export const HookResumeTimingSchema = z.object({ - /** Epoch ms at entry into `resumeHook()`: the start of the TTR window. */ - resumeRequestedAtMs: z.number(), - /** Epoch ms immediately before the queue publish was requested. */ - queuePublishRequestedAtMs: z.number(), - /** - * Which `resumeHook()` dispatch path ran: `lazy` or `sequential` (`parallel` - * from producers predating lazy-only resume). - */ - strategy: z.string().optional(), - /** Epoch ms the final consumer's queue handler was entered. */ - consumerStartedAtMs: z.number().optional(), - /** Epoch ms this invocation's first replay pass began. */ - replayStartedAtMs: z.number().optional(), - /** Epoch ms replay first encountered a durable step after the resume. */ - nextStepEncounteredAtMs: z.number().optional(), - /** How the consumer initialized replay state: see `ResumeSetupSource`. */ - setupSource: z.string().optional(), -}); +export const HookResumeTimingSchema = z.compile( + z.object({ + /** Epoch ms at entry into `resumeHook()`: the start of the TTR window. */ + resumeRequestedAtMs: z.number(), + /** Epoch ms immediately before the queue publish was requested. */ + queuePublishRequestedAtMs: z.number(), + /** + * Which `resumeHook()` dispatch path ran: `lazy` or `sequential` (`parallel` + * from producers predating lazy-only resume). + */ + strategy: z.string().optional(), + /** Epoch ms the final consumer's queue handler was entered. */ + consumerStartedAtMs: z.number().optional(), + /** Epoch ms this invocation's first replay pass began. */ + replayStartedAtMs: z.number().optional(), + /** Epoch ms replay first encountered a durable step after the resume. */ + nextStepEncounteredAtMs: z.number().optional(), + /** How the consumer initialized replay state: see `ResumeSetupSource`. */ + setupSource: z.string().optional(), + }) +); export type HookResumeTiming = z.infer; export const WorkflowInvokePayloadSchema = z.compile( diff --git a/packages/world/src/runs.ts b/packages/world/src/runs.ts index a5539c5249..c070134620 100644 --- a/packages/world/src/runs.ts +++ b/packages/world/src/runs.ts @@ -3,19 +3,13 @@ import { type SerializedData, SerializedDataSchema } from './serialization.js'; import type { PaginationOptions, ResolveData } from './shared.js'; // Workflow run schemas -export const WorkflowRunStatusSchema = z.enum([ - 'pending', - 'running', - 'completed', - 'failed', - 'cancelled', -]); +export const WorkflowRunStatusSchema = z.compile( + z.enum(['pending', 'running', 'completed', 'failed', 'cancelled']) +); export type WorkflowRunStatus = z.infer; -export const TerminalWorkflowRunStatusSchema = WorkflowRunStatusSchema.extract([ - 'completed', - 'failed', - 'cancelled', -] as const); +export const TerminalWorkflowRunStatusSchema = z.compile( + WorkflowRunStatusSchema.extract(['completed', 'failed', 'cancelled'] as const) +); export type TerminalWorkflowRunStatus = z.infer< typeof TerminalWorkflowRunStatusSchema >; @@ -38,94 +32,96 @@ export function isTerminalWorkflowRunStatus( * - specVersion >= 2: Uint8Array (binary devalue format) * - specVersion 1: any (legacy JSON format) */ -export const WorkflowRunBaseSchema = z.object({ - runId: z.string(), - status: WorkflowRunStatusSchema, - deploymentId: z.string(), - /** - * The machine-readable name of the workflow function. - * - * This field contains a structured identifier like `workflow//./src/workflows/order//processOrder` - * that encodes the workflow's module specifier and function name. - * - * Use `parseWorkflowName()` from `@workflow/utils/parse-name` to extract: - * - `shortName`: User-friendly display name (e.g., `"processOrder"`) - * - `moduleSpecifier`: The module path or package (e.g., `"./src/workflows/order"`) - * - `functionName`: The full function path (e.g., `"processOrder"`) - * - * @example - * ```ts - * import { parseWorkflowName } from "@workflow/utils/parse-name"; - * - * const parsed = parseWorkflowName(run.workflowName); - * // parsed.shortName → "processOrder" - * // parsed.moduleSpecifier → "./src/workflows/order" - * ``` - */ - workflowName: z.string(), - // Optional in database for backward compatibility, defaults to 1 (legacy) when reading - specVersion: z.number().optional(), - executionContext: z.record(z.string(), z.any()).optional(), - input: SerializedDataSchema.optional(), - output: SerializedDataSchema.optional(), - /** - * The thrown value from a run_failed event, serialized via the workflow - * serialization pipeline. To display the error to a user, hydrate it via - * `hydrateRunError` (with the encryption key if encryption is enabled). - * Observability tools cannot view the error without going through the - * decryption + hydration pipeline. - */ - error: SerializedDataSchema.optional(), - /** - * The high-level error category (USER_ERROR, RUNTIME_ERROR, etc.) from a - * run_failed event. Kept as plaintext metadata for routing and filtering - * without needing to decrypt the full error payload. - */ - errorCode: z.string().optional(), - /** - * Plaintext string-string metadata attached to the run via - * `setAttributes()` (or, in the future, materialized - * from `attr_set` events). Stored unencrypted alongside other - * plaintext fields so observability surfaces can read it without - * going through the decryption pipeline. - * - * Defaults to `{}` after schema parsing so consumers always receive - * a record regardless of world. World adapters need not initialize - * the field on disk: `world-local` JSON files written before this - * field existed, and rows from any other adapter that omits the - * column, both read as `{}` after Zod parses them. - * - * EXPERIMENTAL (MVP): the full Workflow Attributes feature replaces - * the direct-mutation MVP path with an event-sourced model. See - * the attributes-mvp changelog entry. - */ - attributes: z.record(z.string(), z.string()).default({}), - /** - * The run's X25519 public key, base64-encoded (~44 chars). - * - * Lets any party that can read this run seal a payload *to* it without - * being able to read the run's data. This is used for cross-run writes - * such as a hook resumption from another deployment, or a child workflow - * writing into a forwarded stream. The matching private scalar is never - * stored: it is re-derived on demand from the deployment's own key - * material, so this field is not secret and its presence does not weaken - * the run's confidentiality. - * - * Stamped at run creation by SDKs that support sealed (`encp`) envelopes. - * **Presence is the writer-side gate**: a run only carries a public key if - * the runtime that created it could also open sealed payloads, and runs are - * pinned to their creating deployment. Writers therefore seal iff this - * field is set, and otherwise fall back to fetching the symmetric per-run - * key. Absent on runs created by older SDKs, and on worlds that do not - * implement `getEncryptionKeyForRun` (encryption disabled). - */ - encryptionPublicKey: z.string().optional(), - expiredAt: z.coerce.date().optional(), - startedAt: z.coerce.date().optional(), - completedAt: z.coerce.date().optional(), - createdAt: z.coerce.date(), - updatedAt: z.coerce.date(), -}); +export const WorkflowRunBaseSchema = z.compile( + z.object({ + runId: z.string(), + status: WorkflowRunStatusSchema, + deploymentId: z.string(), + /** + * The machine-readable name of the workflow function. + * + * This field contains a structured identifier like `workflow//./src/workflows/order//processOrder` + * that encodes the workflow's module specifier and function name. + * + * Use `parseWorkflowName()` from `@workflow/utils/parse-name` to extract: + * - `shortName`: User-friendly display name (e.g., `"processOrder"`) + * - `moduleSpecifier`: The module path or package (e.g., `"./src/workflows/order"`) + * - `functionName`: The full function path (e.g., `"processOrder"`) + * + * @example + * ```ts + * import { parseWorkflowName } from "@workflow/utils/parse-name"; + * + * const parsed = parseWorkflowName(run.workflowName); + * // parsed.shortName → "processOrder" + * // parsed.moduleSpecifier → "./src/workflows/order" + * ``` + */ + workflowName: z.string(), + // Optional in database for backward compatibility, defaults to 1 (legacy) when reading + specVersion: z.number().optional(), + executionContext: z.record(z.string(), z.any()).optional(), + input: SerializedDataSchema.optional(), + output: SerializedDataSchema.optional(), + /** + * The thrown value from a run_failed event, serialized via the workflow + * serialization pipeline. To display the error to a user, hydrate it via + * `hydrateRunError` (with the encryption key if encryption is enabled). + * Observability tools cannot view the error without going through the + * decryption + hydration pipeline. + */ + error: SerializedDataSchema.optional(), + /** + * The high-level error category (USER_ERROR, RUNTIME_ERROR, etc.) from a + * run_failed event. Kept as plaintext metadata for routing and filtering + * without needing to decrypt the full error payload. + */ + errorCode: z.string().optional(), + /** + * Plaintext string-string metadata attached to the run via + * `setAttributes()` (or, in the future, materialized + * from `attr_set` events). Stored unencrypted alongside other + * plaintext fields so observability surfaces can read it without + * going through the decryption pipeline. + * + * Defaults to `{}` after schema parsing so consumers always receive + * a record regardless of world. World adapters need not initialize + * the field on disk: `world-local` JSON files written before this + * field existed, and rows from any other adapter that omits the + * column, both read as `{}` after Zod parses them. + * + * EXPERIMENTAL (MVP): the full Workflow Attributes feature replaces + * the direct-mutation MVP path with an event-sourced model. See + * the attributes-mvp changelog entry. + */ + attributes: z.record(z.string(), z.string()).default({}), + /** + * The run's X25519 public key, base64-encoded (~44 chars). + * + * Lets any party that can read this run seal a payload *to* it without + * being able to read the run's data. This is used for cross-run writes + * such as a hook resumption from another deployment, or a child workflow + * writing into a forwarded stream. The matching private scalar is never + * stored: it is re-derived on demand from the deployment's own key + * material, so this field is not secret and its presence does not weaken + * the run's confidentiality. + * + * Stamped at run creation by SDKs that support sealed (`encp`) envelopes. + * **Presence is the writer-side gate**: a run only carries a public key if + * the runtime that created it could also open sealed payloads, and runs are + * pinned to their creating deployment. Writers therefore seal iff this + * field is set, and otherwise fall back to fetching the symmetric per-run + * key. Absent on runs created by older SDKs, and on worlds that do not + * implement `getEncryptionKeyForRun` (encryption disabled). + */ + encryptionPublicKey: z.string().optional(), + expiredAt: z.coerce.date().optional(), + startedAt: z.coerce.date().optional(), + completedAt: z.coerce.date().optional(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), + }) +); // Discriminated union based on status export const WorkflowRunSchema = z.compile( diff --git a/packages/world/src/schema-compilation.test.ts b/packages/world/src/schema-compilation.test.ts index 341ca29e44..1d065d0c3f 100644 --- a/packages/world/src/schema-compilation.test.ts +++ b/packages/world/src/schema-compilation.test.ts @@ -1,59 +1,107 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import type * as z from 'zod/v4'; -import { - AnalyticsAttributeKeySchema, - AnalyticsEventSchema, - AnalyticsHookSchema, - AnalyticsRunSchema, - AnalyticsStepSchema, - AnalyticsWaitSchema, -} from './analytics.js'; -import { AttributeChangesSchema } from './attributes.js'; -import { CreateEventSchema, EventSchema } from './events.js'; -import { HookSchema } from './hooks.js'; -import { - HealthCheckPayloadSchema, - QueuePayloadSchema, - WorkflowInvokePayloadSchema, -} from './queue.js'; -import { - BulkCancelWorkflowRunResultSchema, - BulkCancelWorkflowRunsRequestSchema, - BulkCancelWorkflowRunsResultSchema, - WorkflowRunSchema, -} from './runs.js'; -import { StructuredErrorSchema } from './shared.js'; -import { StepSchema } from './steps.js'; -import { WaitSchema } from './waits.js'; - -// Keep compilation explicit and library-owned. Importing `zod/compile` would -// change schema construction globally in every application that loads Workflow. -const hotPathSchemas = { - AnalyticsAttributeKeySchema, - AnalyticsEventSchema, - AnalyticsHookSchema, - AnalyticsRunSchema, - AnalyticsStepSchema, - AnalyticsWaitSchema, - AttributeChangesSchema, - BulkCancelWorkflowRunResultSchema, - BulkCancelWorkflowRunsRequestSchema, - BulkCancelWorkflowRunsResultSchema, - CreateEventSchema, - EventSchema, - HealthCheckPayloadSchema, - HookSchema, - QueuePayloadSchema, - StepSchema, - StructuredErrorSchema, - WaitSchema, - WorkflowInvokePayloadSchema, - WorkflowRunSchema, -} satisfies Record; +import * as z from 'zod/v4'; +import * as worldExports from './index.js'; +import { zodJsonSchema } from './shared.js'; + +function isSchemaEntry(entry: [string, unknown]): entry is [string, z.ZodType] { + const value = entry[1]; + return typeof value === 'object' && value !== null && '_zod' in value; +} + +const publicSchemas: Record = Object.fromEntries( + Object.entries(worldExports).filter(isSchemaEntry) +); + +const repositoryRoot = fileURLToPath(new URL('../../../', import.meta.url)); +const runtimeSourceDirectories = [ + 'packages/world/src', + 'packages/world-local/src', + 'packages/world-postgres/src', + 'packages/world-testing/src', + 'packages/world-testing/workflows', + 'packages/world-vercel/src', +].map((directory) => join(repositoryRoot, directory)); + +function listRuntimeSourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) return listRuntimeSourceFiles(path); + if (!/\.[cm]?tsx?$/.test(entry.name)) return []; + if (/\.(?:test|spec)\.[cm]?tsx?$/.test(entry.name)) return []; + return [path]; + }); +} + +const zodRuntimeModules = runtimeSourceDirectories + .flatMap(listRuntimeSourceFiles) + .filter((path) => + readFileSync(path, 'utf8').includes("import * as z from 'zod/v4';") + ) + .map((path) => ({ + label: relative(repositoryRoot, path), + path, + })); describe('schema compilation', () => { - it.each(Object.entries(hotPathSchemas))('precompiles $0', (_name, schema) => { - const compilerState = schema._zod.bag as { validator?: unknown }; - expect(compilerState.validator).toBeTypeOf('function'); + it.each( + Object.entries(publicSchemas) + )('precompiles public schema $0', (_name, schema) => { + expect(schema._zod.bag.validator).toBeTypeOf('function'); + }); + + it.each(zodRuntimeModules)('explicitly compiles supported roots in $label', ({ + path, + }) => { + const source = readFileSync(path, 'utf8'); + const directSchemaDeclaration = + /^(?:export\s+)?const\s+([A-Za-z_$][\w$]*)[^\n=]*=\s*z\s*\.(?!compile\b)/gm; + const derivedSchemaDeclaration = + /^(?:export\s+)?const\s+([A-Za-z_$][\w$]*Schema)\b[^\n=]*=\s*[A-Za-z_$][\w$]*Schema\s*\.(?!options\b)/gm; + const uncompiledRoots = [ + ...source.matchAll(directSchemaDeclaration), + ...source.matchAll(derivedSchemaDeclaration), + ].map((match) => match[1]); + + const unsupportedRoots = + path === join(repositoryRoot, 'packages/world/src/shared.ts') + ? ['zodJsonSchema'] + : []; + expect(uncompiledRoots).toEqual(unsupportedRoots); + expect(source).not.toContain("'zod/compile'"); + expect(source).not.toContain('"zod/compile"'); + }); + + it('does not enable compilation for consumer-owned schemas', () => { + const consumerSchema = z.object({ value: z.string() }); + expect(consumerSchema._zod.bag.validator).toBeUndefined(); + consumerSchema.parse({ value: 'ok' }); + expect(consumerSchema._zod.bag.validator).toBeUndefined(); + }); + + it('does not compile schemas passed to the paginated response factory', () => { + let refinementCalls = 0; + const consumerSchema = z.string().refine(() => { + refinementCalls++; + return false; + }); + const responseSchema = worldExports.PaginatedResponseSchema(consumerSchema); + + expect(responseSchema._zod.bag.validator).toBeUndefined(); + responseSchema.safeParse({ + data: ['invalid'], + cursor: null, + hasMore: false, + }); + expect(refinementCalls).toBe(1); + }); + + it('keeps the unsupported recursive JSON schema on the runtime parser', () => { + expect(zodJsonSchema._zod.bag.validator).toBeUndefined(); + expect(zodJsonSchema.parse({ nested: ['value', 1, true, null] })).toEqual({ + nested: ['value', 1, true, null], + }); }); }); diff --git a/packages/world/src/serialization.ts b/packages/world/src/serialization.ts index 64f1990369..35d51d9512 100644 --- a/packages/world/src/serialization.ts +++ b/packages/world/src/serialization.ts @@ -13,14 +13,17 @@ export type SerializedData = Uint8Array | unknown; * Zod schema for validating SerializedData (Uint8Array). * Used for specVersion >= 2. */ -export const BinarySerializedDataSchema: z.ZodType = - z.instanceof(Uint8Array) as z.ZodType; +export const BinarySerializedDataSchema: z.ZodType = z.compile( + z.instanceof(Uint8Array) +) as z.ZodType; /** * Legacy schema for serialized data (specVersion 1). * Legacy data was stored as JSON, so it can be any value. */ -export const LegacySerializedDataSchemaV1: z.ZodType = z.any(); +export const LegacySerializedDataSchemaV1: z.ZodType = z.compile( + z.any() +); /** * Union schema that accepts both v2+ (Uint8Array) and legacy (any) serialized data. @@ -35,6 +38,6 @@ export const LegacySerializedDataSchemaV1: z.ZodType = z.any(); * still see these keys as present, and only the parse-time tolerance is * restored. */ -export const SerializedDataSchema = z - .union([BinarySerializedDataSchema, LegacySerializedDataSchemaV1]) - .optional() as unknown as z.ZodType; +export const SerializedDataSchema = z.compile( + z.union([BinarySerializedDataSchema, LegacySerializedDataSchemaV1]).optional() +) as unknown as z.ZodType; diff --git a/packages/world/src/shared.ts b/packages/world/src/shared.ts index f92d598893..d2a9eaa8b3 100644 --- a/packages/world/src/shared.ts +++ b/packages/world/src/shared.ts @@ -1,5 +1,7 @@ import * as z from 'zod/v4'; +// Zod 4.5 cannot compile recursive schemas. Keep this on the standard parser; +// wrapping it in z.compile() would silently return this same schema unchanged. export const zodJsonSchema: z.ZodType = z.lazy(() => { return z.union([ z.string(), @@ -25,13 +27,15 @@ export interface PaginationOptions { sortOrder?: 'asc' | 'desc'; } -export const PageInfoSchema = z.object({ - currentLookbackDays: z.number(), - maxLookbackDays: z.number(), - currentWindowStart: z.coerce.date(), - maxWindowStart: z.coerce.date(), - upgradeAvailable: z.boolean(), -}); +export const PageInfoSchema = z.compile( + z.object({ + currentLookbackDays: z.number(), + maxLookbackDays: z.number(), + currentWindowStart: z.coerce.date(), + maxWindowStart: z.coerce.date(), + upgradeAvailable: z.boolean(), + }) +); export type PageInfo = z.infer; diff --git a/packages/world/src/steps.ts b/packages/world/src/steps.ts index a4b5a7ba94..09c633810b 100644 --- a/packages/world/src/steps.ts +++ b/packages/world/src/steps.ts @@ -3,13 +3,9 @@ import { type SerializedData, SerializedDataSchema } from './serialization.js'; import type { PaginationOptions, ResolveData } from './shared.js'; // Step schemas -export const StepStatusSchema = z.enum([ - 'pending', - 'running', - 'completed', - 'failed', - 'cancelled', -]); +export const StepStatusSchema = z.compile( + z.enum(['pending', 'running', 'completed', 'failed', 'cancelled']) +); /** * Schema for workflow steps. @@ -75,11 +71,9 @@ export const StepSchema = z.compile( // Inferred types export type StepStatus = z.infer; -export const TerminalStepStatusSchema = StepStatusSchema.extract([ - 'completed', - 'failed', - 'cancelled', -] as const); +export const TerminalStepStatusSchema = z.compile( + StepStatusSchema.extract(['completed', 'failed', 'cancelled'] as const) +); export type TerminalStepStatus = z.infer; export const TERMINAL_STEP_STATUSES = TerminalStepStatusSchema.options; diff --git a/packages/world/src/ulid.ts b/packages/world/src/ulid.ts index e8be97fe3f..ac38f4fb86 100644 --- a/packages/world/src/ulid.ts +++ b/packages/world/src/ulid.ts @@ -2,7 +2,7 @@ import { decodeTime } from 'ulid'; import * as z from 'zod/v4'; import { isSlotBody } from './slot-identity.js'; -const UlidSchema = z.string().ulid(); +const UlidSchema = z.compile(z.string().ulid()); /** * A workflow run ID: the `wrun_` prefix followed by a 26-char ULID (minted @@ -10,7 +10,9 @@ const UlidSchema = z.string().ulid(); * well-formed ULID) rather than a loose length bound, so callers can't smuggle * arbitrary strings through APIs that persist a run ID verbatim. */ -export const workflowRunIdSchema = z.templateLiteral(['wrun_', z.ulid()]); +export const workflowRunIdSchema = z.compile( + z.templateLiteral(['wrun_', z.ulid()]) +); export type WorkflowRunId = z.infer; diff --git a/packages/world/src/waits.ts b/packages/world/src/waits.ts index 94a15b4a6e..81d2f8416c 100644 --- a/packages/world/src/waits.ts +++ b/packages/world/src/waits.ts @@ -1,6 +1,6 @@ import * as z from 'zod/v4'; -export const WaitStatusSchema = z.enum(['waiting', 'completed']); +export const WaitStatusSchema = z.compile(z.enum(['waiting', 'completed'])); export const WaitSchema = z.compile( z.object({ From 9e6421e28bd055a70bc4ee593de3dbbd03aeb3c4 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:39:34 -0700 Subject: [PATCH 05/13] Use the Zod package root Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- .changeset/zod-45-compiled-schemas.md | 2 +- docs/source.config.ts | 2 +- packages/ai/src/agent/durable-agent-compat.test.ts | 2 +- packages/ai/src/agent/durable-agent-types.test.ts | 2 +- packages/ai/src/agent/durable-agent.test.ts | 2 +- packages/ai/src/agent/durable-agent.ts | 2 +- packages/ai/src/agent/telemetry.test.ts | 2 +- packages/ai/src/agent/tools-to-model-tools.test.ts | 2 +- packages/world-local/src/fs.test.ts | 2 +- packages/world-local/src/fs.ts | 2 +- packages/world-local/src/queue.test.ts | 2 +- packages/world-local/src/queue.ts | 2 +- packages/world-local/src/storage/events-storage.ts | 2 +- packages/world-local/src/storage/helpers.ts | 2 +- packages/world-local/src/storage/hook-index.ts | 2 +- packages/world-local/src/storage/hooks-storage.ts | 2 +- packages/world-local/src/streamer.ts | 2 +- packages/world-postgres/src/message.ts | 2 +- packages/world-postgres/src/queue.ts | 2 +- packages/world-postgres/src/streamer.ts | 2 +- packages/world-postgres/src/zod.ts | 2 +- packages/world-testing/src/server.mts | 2 +- packages/world-testing/src/util.mts | 2 +- packages/world-testing/workflows/hooks.ts | 2 +- packages/world-vercel/src/encryption.ts | 2 +- packages/world-vercel/src/event-retry.ts | 2 +- packages/world-vercel/src/events-v4.ts | 2 +- packages/world-vercel/src/frames.ts | 2 +- packages/world-vercel/src/hooks.ts | 2 +- packages/world-vercel/src/queue.ts | 2 +- packages/world-vercel/src/resolve-latest-deployment.ts | 2 +- packages/world-vercel/src/runs.ts | 2 +- packages/world-vercel/src/steps.ts | 2 +- packages/world-vercel/src/streamer.ts | 2 +- packages/world-vercel/src/trace-propagation.test.ts | 2 +- packages/world-vercel/src/utils.test.ts | 2 +- packages/world-vercel/src/utils.ts | 2 +- packages/world-vercel/src/ws-protocol-conformance.test.ts | 2 +- packages/world/src/analytics.ts | 2 +- packages/world/src/attributes.ts | 2 +- packages/world/src/events.ts | 2 +- packages/world/src/hooks.ts | 2 +- packages/world/src/queue.ts | 2 +- packages/world/src/runs.ts | 2 +- packages/world/src/schema-compilation.test.ts | 4 ++-- packages/world/src/serialization.ts | 2 +- packages/world/src/shared.test.ts | 2 +- packages/world/src/shared.ts | 2 +- packages/world/src/steps.ts | 2 +- packages/world/src/ulid.ts | 2 +- packages/world/src/waits.ts | 2 +- workbench/example/workflows/100_durable_agent_e2e.ts | 2 +- workbench/example/workflows/4_ai.ts | 2 +- workbench/nextjs-turbopack/workflows/agent_chat.ts | 2 +- workbench/vitest/workflows/cookbook/child-workflows.ts | 2 +- 55 files changed, 56 insertions(+), 56 deletions(-) diff --git a/.changeset/zod-45-compiled-schemas.md b/.changeset/zod-45-compiled-schemas.md index f32e14c406..eb42abbdb1 100644 --- a/.changeset/zod-45-compiled-schemas.md +++ b/.changeset/zod-45-compiled-schemas.md @@ -10,4 +10,4 @@ 'workflow': patch --- -Upgrade runtime validation to Zod 4.5, use the stable Zod 4 subpath, and enable compilation on all supported SDK-owned Zod schemas. +Upgrade runtime validation to Zod 4.5, use namespace imports from the package root, and enable compilation on all supported SDK-owned Zod schemas. diff --git a/docs/source.config.ts b/docs/source.config.ts index a834cd59cb..6cf785021b 100644 --- a/docs/source.config.ts +++ b/docs/source.config.ts @@ -4,7 +4,7 @@ import { geistdocsMetaSchema, } from '@vercel/geistdocs/source-config'; import { defineDocs } from 'fumadocs-mdx/config'; -import * as z from 'zod/v4'; +import * as z from 'zod'; // You can customise Zod schemas for frontmatter and `meta.json` here // see https://fumadocs.dev/docs/mdx/collections diff --git a/packages/ai/src/agent/durable-agent-compat.test.ts b/packages/ai/src/agent/durable-agent-compat.test.ts index 9c70842f90..3820a1a7c2 100644 --- a/packages/ai/src/agent/durable-agent-compat.test.ts +++ b/packages/ai/src/agent/durable-agent-compat.test.ts @@ -16,7 +16,7 @@ import type { UIMessageChunk } from 'ai'; import { tool } from 'ai'; import { convertArrayToReadableStream, MockLanguageModelV3 } from 'ai/test'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import { DurableAgent } from './durable-agent.js'; // ============================================================================ diff --git a/packages/ai/src/agent/durable-agent-types.test.ts b/packages/ai/src/agent/durable-agent-types.test.ts index d20d36b605..fd10a06fa0 100644 --- a/packages/ai/src/agent/durable-agent-types.test.ts +++ b/packages/ai/src/agent/durable-agent-types.test.ts @@ -1,6 +1,6 @@ import { type InferUITools, tool, type UIMessage } from 'ai'; import { describe, expectTypeOf, it } from 'vitest'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import { DurableAgent, type InferDurableAgentTools, diff --git a/packages/ai/src/agent/durable-agent.test.ts b/packages/ai/src/agent/durable-agent.test.ts index 8d5b3abd43..3e294f56c3 100644 --- a/packages/ai/src/agent/durable-agent.test.ts +++ b/packages/ai/src/agent/durable-agent.test.ts @@ -13,7 +13,7 @@ import type { } from '@ai-sdk/provider'; import type { StepResult, ToolSet } from 'ai'; import { describe, expect, it, vi } from 'vitest'; -import * as z from 'zod/v4'; +import * as z from 'zod'; class FatalError extends Error { constructor(message: string) { diff --git a/packages/ai/src/agent/durable-agent.ts b/packages/ai/src/agent/durable-agent.ts index dc78668a53..c678a9c46e 100644 --- a/packages/ai/src/agent/durable-agent.ts +++ b/packages/ai/src/agent/durable-agent.ts @@ -554,7 +554,7 @@ export interface DurableAgentStreamOptions< * @example * ```typescript * import { Output } from '@workflow/ai'; - * import * as z from 'zod/v4'; + * import * as z from 'zod'; * * const result = await agent.stream({ * messages: [...], diff --git a/packages/ai/src/agent/telemetry.test.ts b/packages/ai/src/agent/telemetry.test.ts index 82416000e1..1886e1e272 100644 --- a/packages/ai/src/agent/telemetry.test.ts +++ b/packages/ai/src/agent/telemetry.test.ts @@ -7,7 +7,7 @@ import type { LanguageModelV3StreamPart, } from '@ai-sdk/provider'; import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; -import * as z from 'zod/v4'; +import * as z from 'zod'; // ── Mock span that captures all setAttributes calls ────────────────────── function createMockSpan() { diff --git a/packages/ai/src/agent/tools-to-model-tools.test.ts b/packages/ai/src/agent/tools-to-model-tools.test.ts index ee283604b4..8d118ae4ab 100644 --- a/packages/ai/src/agent/tools-to-model-tools.test.ts +++ b/packages/ai/src/agent/tools-to-model-tools.test.ts @@ -1,6 +1,6 @@ import { tool } from 'ai'; import { describe, expect, it } from 'vitest'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import { toolsToModelTools } from './tools-to-model-tools.js'; describe('toolsToModelTools', () => { diff --git a/packages/world-local/src/fs.test.ts b/packages/world-local/src/fs.test.ts index 9d7bd6d46b..288ee5d077 100644 --- a/packages/world-local/src/fs.test.ts +++ b/packages/world-local/src/fs.test.ts @@ -14,7 +14,7 @@ import { it, vi, } from 'vitest'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import { UnwritableDataDirError } from './build-target-mismatch.js'; import { assertSafeEntityId, diff --git a/packages/world-local/src/fs.ts b/packages/world-local/src/fs.ts index a290f7eb9e..468454b527 100644 --- a/packages/world-local/src/fs.ts +++ b/packages/world-local/src/fs.ts @@ -4,7 +4,7 @@ import { EntityConflictError, WorkflowWorldError } from '@workflow/errors'; import { globalSingleton } from '@workflow/utils'; import type { PaginatedResponse } from '@workflow/world'; import { monotonicFactory } from 'ulid'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import { isUnwritableDirCode, UnwritableDataDirError, diff --git a/packages/world-local/src/queue.test.ts b/packages/world-local/src/queue.test.ts index cff5ba5091..8862898489 100644 --- a/packages/world-local/src/queue.test.ts +++ b/packages/world-local/src/queue.test.ts @@ -4,7 +4,7 @@ import { setWorkflowBasePath } from '@workflow/utils'; import type { WorkflowInvokePayload } from '@workflow/world'; import { MessageId, NODE_HTTP_ENV_VAR, ValidQueueName } from '@workflow/world'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import { createQueue, DEFAULT_BODY_TIMEOUT_MS, diff --git a/packages/world-local/src/queue.ts b/packages/world-local/src/queue.ts index eb5526c69c..aa018b7fb7 100644 --- a/packages/world-local/src/queue.ts +++ b/packages/world-local/src/queue.ts @@ -17,7 +17,7 @@ import { import { Sema } from 'async-sema'; import { monotonicFactory } from 'ulid'; import { Agent } from 'undici'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import type { Config } from './config.js'; import { resolveBaseUrl, resolveDirectBaseUrl } from './config.js'; import { jsonReplacer, jsonReviver } from './fs.js'; diff --git a/packages/world-local/src/storage/events-storage.ts b/packages/world-local/src/storage/events-storage.ts index cff62f8d21..a0593bc361 100644 --- a/packages/world-local/src/storage/events-storage.ts +++ b/packages/world-local/src/storage/events-storage.ts @@ -53,7 +53,7 @@ import { WaitSchema, WorkflowRunSchema, } from '@workflow/world'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import { DEFAULT_RESOLVE_DATA_OPTION } from '../config.js'; import { assertSafeEntityId, diff --git a/packages/world-local/src/storage/helpers.ts b/packages/world-local/src/storage/helpers.ts index 97946f35f1..ef82107261 100644 --- a/packages/world-local/src/storage/helpers.ts +++ b/packages/world-local/src/storage/helpers.ts @@ -6,7 +6,7 @@ import { globalSingleton } from '@workflow/utils'; import { eventIdToSlot } from '@workflow/world'; import { lock } from 'proper-lockfile'; import { decodeTime, monotonicFactory } from 'ulid'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import { deleteJSON, hasTag, diff --git a/packages/world-local/src/storage/hook-index.ts b/packages/world-local/src/storage/hook-index.ts index a0ad1df6cd..13262378f8 100644 --- a/packages/world-local/src/storage/hook-index.ts +++ b/packages/world-local/src/storage/hook-index.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { globalSingleton } from '@workflow/utils'; import type { Event } from '@workflow/world'; import { EventSchema, HookSchema } from '@workflow/world'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import { assertSafeEntityId, deleteJSON, diff --git a/packages/world-local/src/storage/hooks-storage.ts b/packages/world-local/src/storage/hooks-storage.ts index b85405c2ff..56113492a1 100644 --- a/packages/world-local/src/storage/hooks-storage.ts +++ b/packages/world-local/src/storage/hooks-storage.ts @@ -14,7 +14,7 @@ import { isTerminalWorkflowRunStatus, WorkflowRunSchema, } from '@workflow/world'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import { DEFAULT_RESOLVE_DATA_OPTION } from '../config.js'; import { assertSafeEntityId, diff --git a/packages/world-local/src/streamer.ts b/packages/world-local/src/streamer.ts index 73230ca5e8..5f73e3b092 100644 --- a/packages/world-local/src/streamer.ts +++ b/packages/world-local/src/streamer.ts @@ -9,7 +9,7 @@ import type { StreamInfoResponse, } from '@workflow/world'; import { monotonicFactory } from 'ulid'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import { assertSafeEntityId, readBuffer, diff --git a/packages/world-postgres/src/message.ts b/packages/world-postgres/src/message.ts index cdb9bad59b..663beb3e1c 100644 --- a/packages/world-postgres/src/message.ts +++ b/packages/world-postgres/src/message.ts @@ -1,5 +1,5 @@ import { MessageId } from '@workflow/world'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import { Base64Buffer } from './zod.js'; /** diff --git a/packages/world-postgres/src/queue.ts b/packages/world-postgres/src/queue.ts index 0ff10d7550..597285abb0 100644 --- a/packages/world-postgres/src/queue.ts +++ b/packages/world-postgres/src/queue.ts @@ -29,7 +29,7 @@ import { } from 'graphile-worker'; import type { Pool } from 'pg'; import { monotonicFactory } from 'ulid'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import type { PostgresWorldConfig } from './config.js'; import { MessageData } from './message.js'; diff --git a/packages/world-postgres/src/streamer.ts b/packages/world-postgres/src/streamer.ts index 66343a1056..faadb72b65 100644 --- a/packages/world-postgres/src/streamer.ts +++ b/packages/world-postgres/src/streamer.ts @@ -8,7 +8,7 @@ import type { import { and, asc, eq, gt, sql } from 'drizzle-orm'; import { Client, type Pool } from 'pg'; import { monotonicFactory } from 'ulid'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import { type Drizzle, Schema } from './drizzle/index.js'; import { Mutex } from './util.js'; diff --git a/packages/world-postgres/src/zod.ts b/packages/world-postgres/src/zod.ts index 43c3a6bf70..f9b2288048 100644 --- a/packages/world-postgres/src/zod.ts +++ b/packages/world-postgres/src/zod.ts @@ -1,4 +1,4 @@ -import * as z from 'zod/v4'; +import * as z from 'zod'; export const Base64Buffer = z.compile( z.codec(z.base64(), z.instanceof(Buffer), { diff --git a/packages/world-testing/src/server.mts b/packages/world-testing/src/server.mts index c89705dd5e..50d170f4e5 100644 --- a/packages/world-testing/src/server.mts +++ b/packages/world-testing/src/server.mts @@ -3,7 +3,7 @@ import { serve } from '@hono/node-server'; import { Hono } from 'hono'; import { getHookByToken, getRun, resumeHook, start } from 'workflow/api'; import { getWorld } from 'workflow/runtime'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import { POST as flowPOST } from '../.well-known/workflow/v1/flow.mjs'; import manifest from '../.well-known/workflow/v1/manifest.json' with { type: 'json', diff --git a/packages/world-testing/src/util.mts b/packages/world-testing/src/util.mts index f18be002c8..54001ea365 100644 --- a/packages/world-testing/src/util.mts +++ b/packages/world-testing/src/util.mts @@ -11,7 +11,7 @@ import chalk, { type ChalkInstance } from 'chalk'; import jsonlines from 'jsonlines'; import { assert, onTestFailed, onTestFinished } from 'vitest'; import type { TypedHook } from 'workflow'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import type manifest from '../.well-known/workflow/v1/manifest.json'; export const Control = z.compile( diff --git a/packages/world-testing/workflows/hooks.ts b/packages/world-testing/workflows/hooks.ts index c61fa0379f..090c4239b6 100644 --- a/packages/world-testing/workflows/hooks.ts +++ b/packages/world-testing/workflows/hooks.ts @@ -1,5 +1,5 @@ import { defineHook, getWritable } from '@workflow/core'; -import * as z from 'zod/v4'; +import * as z from 'zod'; export const Hook = defineHook({ schema: z.compile( diff --git a/packages/world-vercel/src/encryption.ts b/packages/world-vercel/src/encryption.ts index e23ba39946..c12170481b 100644 --- a/packages/world-vercel/src/encryption.ts +++ b/packages/world-vercel/src/encryption.ts @@ -12,7 +12,7 @@ import { webcrypto } from 'node:crypto'; import type { WorkflowRun, World } from '@workflow/world'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import { getDispatcher } from './http-client.js'; import { instrumentedFetch, resolveVercelApiToken } from './http-core.js'; diff --git a/packages/world-vercel/src/event-retry.ts b/packages/world-vercel/src/event-retry.ts index 8b20bbe8e3..aab98e402e 100644 --- a/packages/world-vercel/src/event-retry.ts +++ b/packages/world-vercel/src/event-retry.ts @@ -70,7 +70,7 @@ import { WorkflowWorldError, } from '@workflow/errors'; import type { EventTypeSchema } from '@workflow/world'; -import type * as z from 'zod/v4'; +import type * as z from 'zod'; /** Every event type the world knows about (includes the server-only * `hook_conflict`, which the SDK never POSTs). */ diff --git a/packages/world-vercel/src/events-v4.ts b/packages/world-vercel/src/events-v4.ts index 48607d6f9e..7500432db8 100644 --- a/packages/world-vercel/src/events-v4.ts +++ b/packages/world-vercel/src/events-v4.ts @@ -37,7 +37,7 @@ import { WorkflowRunSchema, } from '@workflow/world'; import { decode } from 'cbor-x'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import { type DecodedFrame, decodeFrames, diff --git a/packages/world-vercel/src/frames.ts b/packages/world-vercel/src/frames.ts index f2bba55b81..26945d00f8 100644 --- a/packages/world-vercel/src/frames.ts +++ b/packages/world-vercel/src/frames.ts @@ -9,7 +9,7 @@ */ import { decode, encode } from 'cbor-x'; -import * as z from 'zod/v4'; +import * as z from 'zod'; export const V4_FRAME_CONTENT_TYPE = 'application/vnd.workflow.v4-frames'; diff --git a/packages/world-vercel/src/hooks.ts b/packages/world-vercel/src/hooks.ts index 232c8d0fc5..e5548764fa 100644 --- a/packages/world-vercel/src/hooks.ts +++ b/packages/world-vercel/src/hooks.ts @@ -7,7 +7,7 @@ import type { PaginatedResponse, } from '@workflow/world'; import { HookSchema, PaginatedResponseSchema } from '@workflow/world'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import { normalizeHookData } from './serialized-data.js'; import type { APIConfig } from './utils.js'; import { DEFAULT_RESOLVE_DATA_OPTION, makeRequest } from './utils.js'; diff --git a/packages/world-vercel/src/queue.ts b/packages/world-vercel/src/queue.ts index 49d191a35b..371a359ddf 100644 --- a/packages/world-vercel/src/queue.ts +++ b/packages/world-vercel/src/queue.ts @@ -13,7 +13,7 @@ import { ValidQueueName, } from '@workflow/world'; import { decode as cborDecode, encode as cborEncode } from 'cbor-x'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import { missingDeploymentIdMessage } from './deployment-id.js'; import { getQueueDispatcher } from './http-client.js'; import { decode as decodeTaggedRunId } from './run-id/index.js'; diff --git a/packages/world-vercel/src/resolve-latest-deployment.ts b/packages/world-vercel/src/resolve-latest-deployment.ts index 8020dc1591..d68b899266 100644 --- a/packages/world-vercel/src/resolve-latest-deployment.ts +++ b/packages/world-vercel/src/resolve-latest-deployment.ts @@ -7,7 +7,7 @@ */ import { getVercelOidcToken } from '@vercel/oidc'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import { missingDeploymentIdMessage } from './deployment-id.js'; import { getDispatcher } from './http-client.js'; import { instrumentedFetch, resolveVercelApiToken } from './http-core.js'; diff --git a/packages/world-vercel/src/runs.ts b/packages/world-vercel/src/runs.ts index 86b150dc5b..ec285d0ac3 100644 --- a/packages/world-vercel/src/runs.ts +++ b/packages/world-vercel/src/runs.ts @@ -19,7 +19,7 @@ import { WorkflowRunBaseSchema, type WorkflowRunWithoutData, } from '@workflow/world'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import { getRequestTimeoutMs } from './http-core.js'; import { normalizeWorkflowRunData } from './serialized-data.js'; import type { APIConfig } from './utils.js'; diff --git a/packages/world-vercel/src/steps.ts b/packages/world-vercel/src/steps.ts index d8f352db00..bfe29226e5 100644 --- a/packages/world-vercel/src/steps.ts +++ b/packages/world-vercel/src/steps.ts @@ -10,7 +10,7 @@ import { type StepWithoutData, type UpdateStepRequest, } from '@workflow/world'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import { normalizeStepData } from './serialized-data.js'; import type { APIConfig } from './utils.js'; import { diff --git a/packages/world-vercel/src/streamer.ts b/packages/world-vercel/src/streamer.ts index 6814898a67..9a3ea368c7 100644 --- a/packages/world-vercel/src/streamer.ts +++ b/packages/world-vercel/src/streamer.ts @@ -5,7 +5,7 @@ import { type Streamer, type StreamInfoResponse, } from '@workflow/world'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import { getStreamCloseDispatcher, getStreamDispatcher, diff --git a/packages/world-vercel/src/trace-propagation.test.ts b/packages/world-vercel/src/trace-propagation.test.ts index 73208c17f0..f035d4485d 100644 --- a/packages/world-vercel/src/trace-propagation.test.ts +++ b/packages/world-vercel/src/trace-propagation.test.ts @@ -21,7 +21,7 @@ import { it, vi, } from 'vitest'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import { createHookReceivedPreloadEventV4, getWorkflowRunEventsV4, diff --git a/packages/world-vercel/src/utils.test.ts b/packages/world-vercel/src/utils.test.ts index bb3560af26..1f34ac67f6 100644 --- a/packages/world-vercel/src/utils.test.ts +++ b/packages/world-vercel/src/utils.test.ts @@ -4,7 +4,7 @@ import { PreconditionFailedError, StreamExpiredError } from '@workflow/errors'; import { NODE_HTTP_ENV_VAR } from '@workflow/world'; import { encode } from 'cbor-x'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import { getHeaders, getHttpConfig, diff --git a/packages/world-vercel/src/utils.ts b/packages/world-vercel/src/utils.ts index d34894f72d..59ad56972c 100644 --- a/packages/world-vercel/src/utils.ts +++ b/packages/world-vercel/src/utils.ts @@ -5,7 +5,7 @@ import { WorkflowWorldError } from '@workflow/errors'; import type { SerializedData } from '@workflow/world'; import { nodeHttpFetch } from '@workflow/world/node-http.js'; import { decode, encode } from 'cbor-x'; -import type * as z from 'zod/v4'; +import type * as z from 'zod'; import { getDispatcher, getNodeHttpAgents, diff --git a/packages/world-vercel/src/ws-protocol-conformance.test.ts b/packages/world-vercel/src/ws-protocol-conformance.test.ts index ef59eb9562..ce94152b41 100644 --- a/packages/world-vercel/src/ws-protocol-conformance.test.ts +++ b/packages/world-vercel/src/ws-protocol-conformance.test.ts @@ -22,7 +22,7 @@ import { EntityConflictError } from '@workflow/errors'; import { decode, encode } from 'cbor-x'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import { createWorkflowRunEventV4 } from './events-v4.js'; import { type DecodedFrame, decodeFrames, encodeFrame } from './frames.js'; diff --git a/packages/world/src/analytics.ts b/packages/world/src/analytics.ts index a8f57e9ee0..a5daf2a101 100644 --- a/packages/world/src/analytics.ts +++ b/packages/world/src/analytics.ts @@ -1,4 +1,4 @@ -import * as z from 'zod/v4'; +import * as z from 'zod'; import { EventTypeSchema } from './events.js'; import { WorkflowRunStatusSchema } from './runs.js'; import type { PaginatedResponse, PaginationOptions } from './shared.js'; diff --git a/packages/world/src/attributes.ts b/packages/world/src/attributes.ts index e314ef69b9..ad0de23a45 100644 --- a/packages/world/src/attributes.ts +++ b/packages/world/src/attributes.ts @@ -1,4 +1,4 @@ -import * as z from 'zod/v4'; +import * as z from 'zod'; import { ATTRIBUTE_KEY_MAX_LENGTH, diff --git a/packages/world/src/events.ts b/packages/world/src/events.ts index 58eeaba708..df475098ef 100644 --- a/packages/world/src/events.ts +++ b/packages/world/src/events.ts @@ -1,4 +1,4 @@ -import * as z from 'zod/v4'; +import * as z from 'zod'; import { AttributeChangesSchema } from './attributes.js'; import { getEventDataRefFields } from './event-metadata.js'; import type { Hook } from './hooks.js'; diff --git a/packages/world/src/hooks.ts b/packages/world/src/hooks.ts index 4f6e09e077..7fceb54351 100644 --- a/packages/world/src/hooks.ts +++ b/packages/world/src/hooks.ts @@ -1,4 +1,4 @@ -import * as z from 'zod/v4'; +import * as z from 'zod'; import { TraceCarrierSchema } from './queue.js'; import type { SerializedData } from './serialization.js'; import { SerializedDataSchema } from './serialization.js'; diff --git a/packages/world/src/queue.ts b/packages/world/src/queue.ts index a9067b3a98..6db1bd9f70 100644 --- a/packages/world/src/queue.ts +++ b/packages/world/src/queue.ts @@ -1,4 +1,4 @@ -import * as z from 'zod/v4'; +import * as z from 'zod'; export type QueueKind = 'workflow'; diff --git a/packages/world/src/runs.ts b/packages/world/src/runs.ts index c070134620..ad76e00926 100644 --- a/packages/world/src/runs.ts +++ b/packages/world/src/runs.ts @@ -1,4 +1,4 @@ -import * as z from 'zod/v4'; +import * as z from 'zod'; import { type SerializedData, SerializedDataSchema } from './serialization.js'; import type { PaginationOptions, ResolveData } from './shared.js'; diff --git a/packages/world/src/schema-compilation.test.ts b/packages/world/src/schema-compilation.test.ts index 1d065d0c3f..aa3b0ed427 100644 --- a/packages/world/src/schema-compilation.test.ts +++ b/packages/world/src/schema-compilation.test.ts @@ -2,7 +2,7 @@ import { readdirSync, readFileSync } from 'node:fs'; import { join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import * as worldExports from './index.js'; import { zodJsonSchema } from './shared.js'; @@ -38,7 +38,7 @@ function listRuntimeSourceFiles(directory: string): string[] { const zodRuntimeModules = runtimeSourceDirectories .flatMap(listRuntimeSourceFiles) .filter((path) => - readFileSync(path, 'utf8').includes("import * as z from 'zod/v4';") + readFileSync(path, 'utf8').includes("import * as z from 'zod';") ) .map((path) => ({ label: relative(repositoryRoot, path), diff --git a/packages/world/src/serialization.ts b/packages/world/src/serialization.ts index 35d51d9512..04dec5d37b 100644 --- a/packages/world/src/serialization.ts +++ b/packages/world/src/serialization.ts @@ -1,4 +1,4 @@ -import * as z from 'zod/v4'; +import * as z from 'zod'; /** * Binary serialized data using devalue format. diff --git a/packages/world/src/shared.test.ts b/packages/world/src/shared.test.ts index 9a928c501f..01417122c7 100644 --- a/packages/world/src/shared.test.ts +++ b/packages/world/src/shared.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import { PaginatedResponseSchema } from './shared.js'; describe('PaginatedResponseSchema', () => { diff --git a/packages/world/src/shared.ts b/packages/world/src/shared.ts index d2a9eaa8b3..230c19c57c 100644 --- a/packages/world/src/shared.ts +++ b/packages/world/src/shared.ts @@ -1,4 +1,4 @@ -import * as z from 'zod/v4'; +import * as z from 'zod'; // Zod 4.5 cannot compile recursive schemas. Keep this on the standard parser; // wrapping it in z.compile() would silently return this same schema unchanged. diff --git a/packages/world/src/steps.ts b/packages/world/src/steps.ts index 09c633810b..85c4d9eb06 100644 --- a/packages/world/src/steps.ts +++ b/packages/world/src/steps.ts @@ -1,4 +1,4 @@ -import * as z from 'zod/v4'; +import * as z from 'zod'; import { type SerializedData, SerializedDataSchema } from './serialization.js'; import type { PaginationOptions, ResolveData } from './shared.js'; diff --git a/packages/world/src/ulid.ts b/packages/world/src/ulid.ts index ac38f4fb86..c5a3f398a8 100644 --- a/packages/world/src/ulid.ts +++ b/packages/world/src/ulid.ts @@ -1,5 +1,5 @@ import { decodeTime } from 'ulid'; -import * as z from 'zod/v4'; +import * as z from 'zod'; import { isSlotBody } from './slot-identity.js'; const UlidSchema = z.compile(z.string().ulid()); diff --git a/packages/world/src/waits.ts b/packages/world/src/waits.ts index 81d2f8416c..44f5900a81 100644 --- a/packages/world/src/waits.ts +++ b/packages/world/src/waits.ts @@ -1,4 +1,4 @@ -import * as z from 'zod/v4'; +import * as z from 'zod'; export const WaitStatusSchema = z.compile(z.enum(['waiting', 'completed'])); diff --git a/workbench/example/workflows/100_durable_agent_e2e.ts b/workbench/example/workflows/100_durable_agent_e2e.ts index c1a5429bdb..1acaee918c 100644 --- a/workbench/example/workflows/100_durable_agent_e2e.ts +++ b/workbench/example/workflows/100_durable_agent_e2e.ts @@ -4,7 +4,7 @@ import { DurableAgent } from '@workflow/ai/agent'; import { mockSequenceModel, mockTextModel } from '@workflow/ai/test'; import { FatalError, getWritable } from 'workflow'; -import * as z from 'zod/v4'; +import * as z from 'zod'; // ============================================================================ // Tool step functions diff --git a/workbench/example/workflows/4_ai.ts b/workbench/example/workflows/4_ai.ts index 06affd0292..692f10398b 100644 --- a/workbench/example/workflows/4_ai.ts +++ b/workbench/example/workflows/4_ai.ts @@ -1,6 +1,6 @@ import { generateText, stepCountIs } from 'ai'; import { FatalError } from 'workflow'; -import * as z from 'zod/v4'; +import * as z from 'zod'; async function getWeatherInformation({ city }: { city: string }) { 'use step'; diff --git a/workbench/nextjs-turbopack/workflows/agent_chat.ts b/workbench/nextjs-turbopack/workflows/agent_chat.ts index af97677768..629a0c4552 100644 --- a/workbench/nextjs-turbopack/workflows/agent_chat.ts +++ b/workbench/nextjs-turbopack/workflows/agent_chat.ts @@ -5,7 +5,7 @@ import { type UIMessageChunk, } from 'ai'; import { getWritable } from 'workflow'; -import * as z from 'zod/v4'; +import * as z from 'zod'; // ============================================================================ // Tool step functions diff --git a/workbench/vitest/workflows/cookbook/child-workflows.ts b/workbench/vitest/workflows/cookbook/child-workflows.ts index 10d44dec86..eee9bdb446 100644 --- a/workbench/vitest/workflows/cookbook/child-workflows.ts +++ b/workbench/vitest/workflows/cookbook/child-workflows.ts @@ -1,6 +1,6 @@ import { defineHook } from 'workflow'; import { start } from 'workflow/api'; -import * as z from 'zod/v4'; +import * as z from 'zod'; async function processItem(item: string): Promise { 'use step'; From 70d4b97c0cc262154f0d13b1b9c873f47cf27494 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:14:26 -0700 Subject: [PATCH 06/13] Remove schema compilation source scan Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- packages/world/src/schema-compilation.test.ts | 107 ------------------ 1 file changed, 107 deletions(-) delete mode 100644 packages/world/src/schema-compilation.test.ts diff --git a/packages/world/src/schema-compilation.test.ts b/packages/world/src/schema-compilation.test.ts deleted file mode 100644 index aa3b0ed427..0000000000 --- a/packages/world/src/schema-compilation.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { readdirSync, readFileSync } from 'node:fs'; -import { join, relative } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { describe, expect, it } from 'vitest'; -import * as z from 'zod'; -import * as worldExports from './index.js'; -import { zodJsonSchema } from './shared.js'; - -function isSchemaEntry(entry: [string, unknown]): entry is [string, z.ZodType] { - const value = entry[1]; - return typeof value === 'object' && value !== null && '_zod' in value; -} - -const publicSchemas: Record = Object.fromEntries( - Object.entries(worldExports).filter(isSchemaEntry) -); - -const repositoryRoot = fileURLToPath(new URL('../../../', import.meta.url)); -const runtimeSourceDirectories = [ - 'packages/world/src', - 'packages/world-local/src', - 'packages/world-postgres/src', - 'packages/world-testing/src', - 'packages/world-testing/workflows', - 'packages/world-vercel/src', -].map((directory) => join(repositoryRoot, directory)); - -function listRuntimeSourceFiles(directory: string): string[] { - return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { - const path = join(directory, entry.name); - if (entry.isDirectory()) return listRuntimeSourceFiles(path); - if (!/\.[cm]?tsx?$/.test(entry.name)) return []; - if (/\.(?:test|spec)\.[cm]?tsx?$/.test(entry.name)) return []; - return [path]; - }); -} - -const zodRuntimeModules = runtimeSourceDirectories - .flatMap(listRuntimeSourceFiles) - .filter((path) => - readFileSync(path, 'utf8').includes("import * as z from 'zod';") - ) - .map((path) => ({ - label: relative(repositoryRoot, path), - path, - })); - -describe('schema compilation', () => { - it.each( - Object.entries(publicSchemas) - )('precompiles public schema $0', (_name, schema) => { - expect(schema._zod.bag.validator).toBeTypeOf('function'); - }); - - it.each(zodRuntimeModules)('explicitly compiles supported roots in $label', ({ - path, - }) => { - const source = readFileSync(path, 'utf8'); - const directSchemaDeclaration = - /^(?:export\s+)?const\s+([A-Za-z_$][\w$]*)[^\n=]*=\s*z\s*\.(?!compile\b)/gm; - const derivedSchemaDeclaration = - /^(?:export\s+)?const\s+([A-Za-z_$][\w$]*Schema)\b[^\n=]*=\s*[A-Za-z_$][\w$]*Schema\s*\.(?!options\b)/gm; - const uncompiledRoots = [ - ...source.matchAll(directSchemaDeclaration), - ...source.matchAll(derivedSchemaDeclaration), - ].map((match) => match[1]); - - const unsupportedRoots = - path === join(repositoryRoot, 'packages/world/src/shared.ts') - ? ['zodJsonSchema'] - : []; - expect(uncompiledRoots).toEqual(unsupportedRoots); - expect(source).not.toContain("'zod/compile'"); - expect(source).not.toContain('"zod/compile"'); - }); - - it('does not enable compilation for consumer-owned schemas', () => { - const consumerSchema = z.object({ value: z.string() }); - expect(consumerSchema._zod.bag.validator).toBeUndefined(); - consumerSchema.parse({ value: 'ok' }); - expect(consumerSchema._zod.bag.validator).toBeUndefined(); - }); - - it('does not compile schemas passed to the paginated response factory', () => { - let refinementCalls = 0; - const consumerSchema = z.string().refine(() => { - refinementCalls++; - return false; - }); - const responseSchema = worldExports.PaginatedResponseSchema(consumerSchema); - - expect(responseSchema._zod.bag.validator).toBeUndefined(); - responseSchema.safeParse({ - data: ['invalid'], - cursor: null, - hasMore: false, - }); - expect(refinementCalls).toBe(1); - }); - - it('keeps the unsupported recursive JSON schema on the runtime parser', () => { - expect(zodJsonSchema._zod.bag.validator).toBeUndefined(); - expect(zodJsonSchema.parse({ nested: ['value', 1, true, null] })).toEqual({ - nested: ['value', 1, true, null], - }); - }); -}); From be448be42e9a4c4f3fbbd4b4a653c73b607db95b Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:23:43 -0700 Subject: [PATCH 07/13] Limit Zod migration to schema compilation Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- .changeset/zod-45-compiled-schemas.md | 2 +- docs/source.config.ts | 2 +- .../ai/src/agent/durable-agent-compat.test.ts | 2 +- .../ai/src/agent/durable-agent-types.test.ts | 2 +- packages/ai/src/agent/durable-agent.test.ts | 2 +- packages/ai/src/agent/durable-agent.ts | 2 +- packages/ai/src/agent/telemetry.test.ts | 2 +- .../ai/src/agent/tools-to-model-tools.test.ts | 2 +- packages/world-local/src/fs.test.ts | 2 +- packages/world-local/src/fs.ts | 2 +- packages/world-local/src/queue.test.ts | 2 +- packages/world-local/src/queue.ts | 17 +++++++++-------- .../world-local/src/storage/events-storage.ts | 2 +- packages/world-local/src/storage/helpers.ts | 2 +- packages/world-local/src/storage/hook-index.ts | 2 +- .../world-local/src/storage/hooks-storage.ts | 2 +- packages/world-local/src/streamer.ts | 2 +- packages/world-postgres/src/message.ts | 2 +- packages/world-postgres/src/queue.ts | 2 +- packages/world-postgres/src/zod.ts | 2 +- packages/world-vercel/src/encryption.ts | 7 +++---- packages/world-vercel/src/event-retry.ts | 2 +- packages/world-vercel/src/events-v4.ts | 15 +++++++-------- packages/world-vercel/src/frames.ts | 2 +- packages/world-vercel/src/hooks.ts | 2 +- packages/world-vercel/src/queue.ts | 2 +- packages/world-vercel/src/runs.ts | 2 +- packages/world-vercel/src/steps.ts | 2 +- packages/world-vercel/src/streamer.ts | 2 +- .../world-vercel/src/trace-propagation.test.ts | 2 +- packages/world-vercel/src/utils.test.ts | 2 +- packages/world-vercel/src/utils.ts | 2 +- .../src/ws-protocol-conformance.test.ts | 2 +- packages/world/src/analytics.ts | 2 +- packages/world/src/events.ts | 2 +- packages/world/src/hooks.ts | 2 +- packages/world/src/queue.ts | 2 +- packages/world/src/runs.ts | 2 +- packages/world/src/serialization.ts | 15 +++------------ packages/world/src/shared.test.ts | 2 +- packages/world/src/shared.ts | 4 +--- packages/world/src/steps.ts | 2 +- packages/world/src/ulid.ts | 2 +- packages/world/src/waits.ts | 2 +- .../example/workflows/100_durable_agent_e2e.ts | 2 +- workbench/example/workflows/4_ai.ts | 2 +- .../nextjs-turbopack/workflows/agent_chat.ts | 2 +- .../workflows/cookbook/child-workflows.ts | 2 +- 48 files changed, 66 insertions(+), 78 deletions(-) diff --git a/.changeset/zod-45-compiled-schemas.md b/.changeset/zod-45-compiled-schemas.md index eb42abbdb1..adc73218f2 100644 --- a/.changeset/zod-45-compiled-schemas.md +++ b/.changeset/zod-45-compiled-schemas.md @@ -10,4 +10,4 @@ 'workflow': patch --- -Upgrade runtime validation to Zod 4.5, use namespace imports from the package root, and enable compilation on all supported SDK-owned Zod schemas. +Upgrade runtime validation to Zod 4.5 and enable compilation on SDK-owned Zod schemas. diff --git a/docs/source.config.ts b/docs/source.config.ts index 6cf785021b..d1245665da 100644 --- a/docs/source.config.ts +++ b/docs/source.config.ts @@ -4,7 +4,7 @@ import { geistdocsMetaSchema, } from '@vercel/geistdocs/source-config'; import { defineDocs } from 'fumadocs-mdx/config'; -import * as z from 'zod'; +import { z } from 'zod'; // You can customise Zod schemas for frontmatter and `meta.json` here // see https://fumadocs.dev/docs/mdx/collections diff --git a/packages/ai/src/agent/durable-agent-compat.test.ts b/packages/ai/src/agent/durable-agent-compat.test.ts index 3820a1a7c2..e075b3d355 100644 --- a/packages/ai/src/agent/durable-agent-compat.test.ts +++ b/packages/ai/src/agent/durable-agent-compat.test.ts @@ -16,7 +16,7 @@ import type { UIMessageChunk } from 'ai'; import { tool } from 'ai'; import { convertArrayToReadableStream, MockLanguageModelV3 } from 'ai/test'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import * as z from 'zod'; +import { z } from 'zod'; import { DurableAgent } from './durable-agent.js'; // ============================================================================ diff --git a/packages/ai/src/agent/durable-agent-types.test.ts b/packages/ai/src/agent/durable-agent-types.test.ts index fd10a06fa0..964a1481a0 100644 --- a/packages/ai/src/agent/durable-agent-types.test.ts +++ b/packages/ai/src/agent/durable-agent-types.test.ts @@ -1,6 +1,6 @@ import { type InferUITools, tool, type UIMessage } from 'ai'; import { describe, expectTypeOf, it } from 'vitest'; -import * as z from 'zod'; +import { z } from 'zod'; import { DurableAgent, type InferDurableAgentTools, diff --git a/packages/ai/src/agent/durable-agent.test.ts b/packages/ai/src/agent/durable-agent.test.ts index 3e294f56c3..5e7040c920 100644 --- a/packages/ai/src/agent/durable-agent.test.ts +++ b/packages/ai/src/agent/durable-agent.test.ts @@ -13,7 +13,7 @@ import type { } from '@ai-sdk/provider'; import type { StepResult, ToolSet } from 'ai'; import { describe, expect, it, vi } from 'vitest'; -import * as z from 'zod'; +import { z } from 'zod'; class FatalError extends Error { constructor(message: string) { diff --git a/packages/ai/src/agent/durable-agent.ts b/packages/ai/src/agent/durable-agent.ts index c678a9c46e..383a2c9e15 100644 --- a/packages/ai/src/agent/durable-agent.ts +++ b/packages/ai/src/agent/durable-agent.ts @@ -554,7 +554,7 @@ export interface DurableAgentStreamOptions< * @example * ```typescript * import { Output } from '@workflow/ai'; - * import * as z from 'zod'; + * import { z } from 'zod'; * * const result = await agent.stream({ * messages: [...], diff --git a/packages/ai/src/agent/telemetry.test.ts b/packages/ai/src/agent/telemetry.test.ts index 1886e1e272..716a123035 100644 --- a/packages/ai/src/agent/telemetry.test.ts +++ b/packages/ai/src/agent/telemetry.test.ts @@ -7,7 +7,7 @@ import type { LanguageModelV3StreamPart, } from '@ai-sdk/provider'; import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; -import * as z from 'zod'; +import { z } from 'zod'; // ── Mock span that captures all setAttributes calls ────────────────────── function createMockSpan() { diff --git a/packages/ai/src/agent/tools-to-model-tools.test.ts b/packages/ai/src/agent/tools-to-model-tools.test.ts index 8d118ae4ab..605205a36e 100644 --- a/packages/ai/src/agent/tools-to-model-tools.test.ts +++ b/packages/ai/src/agent/tools-to-model-tools.test.ts @@ -1,6 +1,6 @@ import { tool } from 'ai'; import { describe, expect, it } from 'vitest'; -import * as z from 'zod'; +import { z } from 'zod'; import { toolsToModelTools } from './tools-to-model-tools.js'; describe('toolsToModelTools', () => { diff --git a/packages/world-local/src/fs.test.ts b/packages/world-local/src/fs.test.ts index 288ee5d077..1f647b5bb9 100644 --- a/packages/world-local/src/fs.test.ts +++ b/packages/world-local/src/fs.test.ts @@ -14,7 +14,7 @@ import { it, vi, } from 'vitest'; -import * as z from 'zod'; +import { z } from 'zod'; import { UnwritableDataDirError } from './build-target-mismatch.js'; import { assertSafeEntityId, diff --git a/packages/world-local/src/fs.ts b/packages/world-local/src/fs.ts index 468454b527..b0cbf7413c 100644 --- a/packages/world-local/src/fs.ts +++ b/packages/world-local/src/fs.ts @@ -4,7 +4,7 @@ import { EntityConflictError, WorkflowWorldError } from '@workflow/errors'; import { globalSingleton } from '@workflow/utils'; import type { PaginatedResponse } from '@workflow/world'; import { monotonicFactory } from 'ulid'; -import * as z from 'zod'; +import { z } from 'zod'; import { isUnwritableDirCode, UnwritableDataDirError, diff --git a/packages/world-local/src/queue.test.ts b/packages/world-local/src/queue.test.ts index 8862898489..a243eaca77 100644 --- a/packages/world-local/src/queue.test.ts +++ b/packages/world-local/src/queue.test.ts @@ -4,7 +4,7 @@ import { setWorkflowBasePath } from '@workflow/utils'; import type { WorkflowInvokePayload } from '@workflow/world'; import { MessageId, NODE_HTTP_ENV_VAR, ValidQueueName } from '@workflow/world'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import * as z from 'zod'; +import { z } from 'zod/v4'; import { createQueue, DEFAULT_BODY_TIMEOUT_MS, diff --git a/packages/world-local/src/queue.ts b/packages/world-local/src/queue.ts index aa018b7fb7..a2abd12e35 100644 --- a/packages/world-local/src/queue.ts +++ b/packages/world-local/src/queue.ts @@ -17,7 +17,7 @@ import { import { Sema } from 'async-sema'; import { monotonicFactory } from 'ulid'; import { Agent } from 'undici'; -import * as z from 'zod'; +import { z } from 'zod/v4'; import type { Config } from './config.js'; import { resolveBaseUrl, resolveDirectBaseUrl } from './config.js'; import { jsonReplacer, jsonReviver } from './fs.js'; @@ -64,13 +64,6 @@ const MAX_SAFE_TIMEOUT_MS = 2147483647; // so we need to limit concurrency to avoid overwhelming the system. const DEFAULT_CONCURRENCY_LIMIT = 1000; -const HeaderParser = z.compile( - z.object({ - 'x-vqs-queue-name': ValidQueueName, - 'x-vqs-message-id': MessageId, - 'x-vqs-message-attempt': z.coerce.number(), - }) -); const WORKFLOW_LOCAL_QUEUE_CONCURRENCY = parseInt(process.env.WORKFLOW_LOCAL_QUEUE_CONCURRENCY ?? '0', 10) || DEFAULT_CONCURRENCY_LIMIT; @@ -405,6 +398,14 @@ export function createQueue(config: Partial): LocalQueue { return { messageId }; }; + const HeaderParser = z.compile( + z.object({ + 'x-vqs-queue-name': ValidQueueName, + 'x-vqs-message-id': MessageId, + 'x-vqs-message-attempt': z.coerce.number(), + }) + ); + const createQueueHandler: Queue['createQueueHandler'] = (prefix, handler) => { return async (req) => { const headers = HeaderParser.safeParse(Object.fromEntries(req.headers)); diff --git a/packages/world-local/src/storage/events-storage.ts b/packages/world-local/src/storage/events-storage.ts index a0593bc361..015207e75e 100644 --- a/packages/world-local/src/storage/events-storage.ts +++ b/packages/world-local/src/storage/events-storage.ts @@ -53,7 +53,7 @@ import { WaitSchema, WorkflowRunSchema, } from '@workflow/world'; -import * as z from 'zod'; +import { z } from 'zod'; import { DEFAULT_RESOLVE_DATA_OPTION } from '../config.js'; import { assertSafeEntityId, diff --git a/packages/world-local/src/storage/helpers.ts b/packages/world-local/src/storage/helpers.ts index ef82107261..9584fec930 100644 --- a/packages/world-local/src/storage/helpers.ts +++ b/packages/world-local/src/storage/helpers.ts @@ -6,7 +6,7 @@ import { globalSingleton } from '@workflow/utils'; import { eventIdToSlot } from '@workflow/world'; import { lock } from 'proper-lockfile'; import { decodeTime, monotonicFactory } from 'ulid'; -import * as z from 'zod'; +import { z } from 'zod'; import { deleteJSON, hasTag, diff --git a/packages/world-local/src/storage/hook-index.ts b/packages/world-local/src/storage/hook-index.ts index 13262378f8..421e0e2fb3 100644 --- a/packages/world-local/src/storage/hook-index.ts +++ b/packages/world-local/src/storage/hook-index.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { globalSingleton } from '@workflow/utils'; import type { Event } from '@workflow/world'; import { EventSchema, HookSchema } from '@workflow/world'; -import * as z from 'zod'; +import { z } from 'zod'; import { assertSafeEntityId, deleteJSON, diff --git a/packages/world-local/src/storage/hooks-storage.ts b/packages/world-local/src/storage/hooks-storage.ts index 56113492a1..79f13a5946 100644 --- a/packages/world-local/src/storage/hooks-storage.ts +++ b/packages/world-local/src/storage/hooks-storage.ts @@ -14,7 +14,7 @@ import { isTerminalWorkflowRunStatus, WorkflowRunSchema, } from '@workflow/world'; -import * as z from 'zod'; +import { z } from 'zod'; import { DEFAULT_RESOLVE_DATA_OPTION } from '../config.js'; import { assertSafeEntityId, diff --git a/packages/world-local/src/streamer.ts b/packages/world-local/src/streamer.ts index 5f73e3b092..195a4b08e3 100644 --- a/packages/world-local/src/streamer.ts +++ b/packages/world-local/src/streamer.ts @@ -9,7 +9,7 @@ import type { StreamInfoResponse, } from '@workflow/world'; import { monotonicFactory } from 'ulid'; -import * as z from 'zod'; +import { z } from 'zod'; import { assertSafeEntityId, readBuffer, diff --git a/packages/world-postgres/src/message.ts b/packages/world-postgres/src/message.ts index 663beb3e1c..52f40aa7fe 100644 --- a/packages/world-postgres/src/message.ts +++ b/packages/world-postgres/src/message.ts @@ -1,5 +1,5 @@ import { MessageId } from '@workflow/world'; -import * as z from 'zod'; +import { z } from 'zod/v4'; import { Base64Buffer } from './zod.js'; /** diff --git a/packages/world-postgres/src/queue.ts b/packages/world-postgres/src/queue.ts index 597285abb0..898960fa7c 100644 --- a/packages/world-postgres/src/queue.ts +++ b/packages/world-postgres/src/queue.ts @@ -29,7 +29,7 @@ import { } from 'graphile-worker'; import type { Pool } from 'pg'; import { monotonicFactory } from 'ulid'; -import * as z from 'zod'; +import { z } from 'zod/v4'; import type { PostgresWorldConfig } from './config.js'; import { MessageData } from './message.js'; diff --git a/packages/world-postgres/src/zod.ts b/packages/world-postgres/src/zod.ts index f9b2288048..448ded4227 100644 --- a/packages/world-postgres/src/zod.ts +++ b/packages/world-postgres/src/zod.ts @@ -1,4 +1,4 @@ -import * as z from 'zod'; +import { z } from 'zod/v4'; export const Base64Buffer = z.compile( z.codec(z.base64(), z.instanceof(Buffer), { diff --git a/packages/world-vercel/src/encryption.ts b/packages/world-vercel/src/encryption.ts index c12170481b..7c05c1625c 100644 --- a/packages/world-vercel/src/encryption.ts +++ b/packages/world-vercel/src/encryption.ts @@ -17,9 +17,6 @@ import { getDispatcher } from './http-client.js'; import { instrumentedFetch, resolveVercelApiToken } from './http-core.js'; const KEY_BYTES = 32; // 256 bits = 32 bytes (AES-256) -const RunKeyResponseSchema = z.compile( - z.object({ key: z.string().nullable() }) -); class RunKeyFetchError extends Error { readonly status: number; @@ -153,7 +150,9 @@ export async function fetchRunKey( }); const data = await response.json(); - const result = RunKeyResponseSchema.safeParse(data); + const result = z + .compile(z.object({ key: z.string().nullable() })) + .safeParse(data); if (!result.success) { throw new Error( `Invalid response from Vercel API: expected { key: string | null }. Zod error: ${result.error.message}` diff --git a/packages/world-vercel/src/event-retry.ts b/packages/world-vercel/src/event-retry.ts index 8a0552f03a..792943a888 100644 --- a/packages/world-vercel/src/event-retry.ts +++ b/packages/world-vercel/src/event-retry.ts @@ -70,7 +70,7 @@ import { WorkflowWorldError, } from '@workflow/errors'; import type { EventTypeSchema } from '@workflow/world'; -import type * as z from 'zod'; +import type { z } from 'zod'; /** Keeps caller-owned replay observer failures out of retry/classification. */ export class ReplayEventObserverError extends Error { diff --git a/packages/world-vercel/src/events-v4.ts b/packages/world-vercel/src/events-v4.ts index abdf4583b1..f64e93ad0b 100644 --- a/packages/world-vercel/src/events-v4.ts +++ b/packages/world-vercel/src/events-v4.ts @@ -37,7 +37,7 @@ import { WorkflowRunSchema, } from '@workflow/world'; import { decode } from 'cbor-x'; -import * as z from 'zod'; +import { z } from 'zod'; import { ReplayEventObserverError } from './event-retry.js'; import { type DecodedFrame, @@ -327,12 +327,10 @@ const CreateEventV4PageSchema = z.compile( cursor: z.string().nullable(), hasMore: z.boolean(), }), - // A response without a page omits these keys outright. Since Zod 4.4, - // `z.undefined()` no longer makes an object key implicitly optional. z.object({ - events: z.undefined().optional(), - cursor: z.undefined().optional(), - hasMore: z.undefined().optional(), + events: z.undefined(), + cursor: z.undefined(), + hasMore: z.undefined(), }), ]) ); @@ -856,13 +854,14 @@ async function decodeCreateEventResponse( code: 'PARSE_ERROR', }); } - const schema: z.ZodType & { event: Event }> = + const schema: z.ZodType & { event: Event }> = z.compile( CreateEventV4BodySchemas[eventType].refine( ({ event }) => event.eventType === eventType || (eventType === 'hook_created' && event.eventType === 'hook_conflict'), { path: ['event', 'eventType'] } - ); + ) + ); let decoded: unknown; try { decoded = decode(bodyBytes); diff --git a/packages/world-vercel/src/frames.ts b/packages/world-vercel/src/frames.ts index 35f5c2ccba..e5564785ac 100644 --- a/packages/world-vercel/src/frames.ts +++ b/packages/world-vercel/src/frames.ts @@ -9,7 +9,7 @@ */ import { decode, encode } from 'cbor-x'; -import * as z from 'zod'; +import { z } from 'zod'; export const V4_FRAME_CONTENT_TYPE = 'application/vnd.workflow.v4-frames'; diff --git a/packages/world-vercel/src/hooks.ts b/packages/world-vercel/src/hooks.ts index e5548764fa..37cad7fa88 100644 --- a/packages/world-vercel/src/hooks.ts +++ b/packages/world-vercel/src/hooks.ts @@ -7,7 +7,7 @@ import type { PaginatedResponse, } from '@workflow/world'; import { HookSchema, PaginatedResponseSchema } from '@workflow/world'; -import * as z from 'zod'; +import z from 'zod'; import { normalizeHookData } from './serialized-data.js'; import type { APIConfig } from './utils.js'; import { DEFAULT_RESOLVE_DATA_OPTION, makeRequest } from './utils.js'; diff --git a/packages/world-vercel/src/queue.ts b/packages/world-vercel/src/queue.ts index 371a359ddf..0541c16de7 100644 --- a/packages/world-vercel/src/queue.ts +++ b/packages/world-vercel/src/queue.ts @@ -13,7 +13,7 @@ import { ValidQueueName, } from '@workflow/world'; import { decode as cborDecode, encode as cborEncode } from 'cbor-x'; -import * as z from 'zod'; +import { z } from 'zod/v4'; import { missingDeploymentIdMessage } from './deployment-id.js'; import { getQueueDispatcher } from './http-client.js'; import { decode as decodeTaggedRunId } from './run-id/index.js'; diff --git a/packages/world-vercel/src/runs.ts b/packages/world-vercel/src/runs.ts index ec285d0ac3..4193d82e22 100644 --- a/packages/world-vercel/src/runs.ts +++ b/packages/world-vercel/src/runs.ts @@ -19,7 +19,7 @@ import { WorkflowRunBaseSchema, type WorkflowRunWithoutData, } from '@workflow/world'; -import * as z from 'zod'; +import { z } from 'zod'; import { getRequestTimeoutMs } from './http-core.js'; import { normalizeWorkflowRunData } from './serialized-data.js'; import type { APIConfig } from './utils.js'; diff --git a/packages/world-vercel/src/steps.ts b/packages/world-vercel/src/steps.ts index bfe29226e5..cd395ab64e 100644 --- a/packages/world-vercel/src/steps.ts +++ b/packages/world-vercel/src/steps.ts @@ -10,7 +10,7 @@ import { type StepWithoutData, type UpdateStepRequest, } from '@workflow/world'; -import * as z from 'zod'; +import { z } from 'zod'; import { normalizeStepData } from './serialized-data.js'; import type { APIConfig } from './utils.js'; import { diff --git a/packages/world-vercel/src/streamer.ts b/packages/world-vercel/src/streamer.ts index 9a3ea368c7..f7e07f8409 100644 --- a/packages/world-vercel/src/streamer.ts +++ b/packages/world-vercel/src/streamer.ts @@ -5,7 +5,7 @@ import { type Streamer, type StreamInfoResponse, } from '@workflow/world'; -import * as z from 'zod'; +import { z } from 'zod'; import { getStreamCloseDispatcher, getStreamDispatcher, diff --git a/packages/world-vercel/src/trace-propagation.test.ts b/packages/world-vercel/src/trace-propagation.test.ts index f035d4485d..9966e3c641 100644 --- a/packages/world-vercel/src/trace-propagation.test.ts +++ b/packages/world-vercel/src/trace-propagation.test.ts @@ -21,7 +21,7 @@ import { it, vi, } from 'vitest'; -import * as z from 'zod'; +import { z } from 'zod'; import { createHookReceivedPreloadEventV4, getWorkflowRunEventsV4, diff --git a/packages/world-vercel/src/utils.test.ts b/packages/world-vercel/src/utils.test.ts index 1f34ac67f6..4dec341201 100644 --- a/packages/world-vercel/src/utils.test.ts +++ b/packages/world-vercel/src/utils.test.ts @@ -4,7 +4,7 @@ import { PreconditionFailedError, StreamExpiredError } from '@workflow/errors'; import { NODE_HTTP_ENV_VAR } from '@workflow/world'; import { encode } from 'cbor-x'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import * as z from 'zod'; +import { z } from 'zod'; import { getHeaders, getHttpConfig, diff --git a/packages/world-vercel/src/utils.ts b/packages/world-vercel/src/utils.ts index 59ad56972c..f89873864b 100644 --- a/packages/world-vercel/src/utils.ts +++ b/packages/world-vercel/src/utils.ts @@ -5,7 +5,7 @@ import { WorkflowWorldError } from '@workflow/errors'; import type { SerializedData } from '@workflow/world'; import { nodeHttpFetch } from '@workflow/world/node-http.js'; import { decode, encode } from 'cbor-x'; -import type * as z from 'zod'; +import type { z } from 'zod'; import { getDispatcher, getNodeHttpAgents, diff --git a/packages/world-vercel/src/ws-protocol-conformance.test.ts b/packages/world-vercel/src/ws-protocol-conformance.test.ts index ce94152b41..deffac7c67 100644 --- a/packages/world-vercel/src/ws-protocol-conformance.test.ts +++ b/packages/world-vercel/src/ws-protocol-conformance.test.ts @@ -22,7 +22,7 @@ import { EntityConflictError } from '@workflow/errors'; import { decode, encode } from 'cbor-x'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import * as z from 'zod'; +import { z } from 'zod'; import { createWorkflowRunEventV4 } from './events-v4.js'; import { type DecodedFrame, decodeFrames, encodeFrame } from './frames.js'; diff --git a/packages/world/src/analytics.ts b/packages/world/src/analytics.ts index a5daf2a101..5f5433809a 100644 --- a/packages/world/src/analytics.ts +++ b/packages/world/src/analytics.ts @@ -1,4 +1,4 @@ -import * as z from 'zod'; +import { z } from 'zod'; import { EventTypeSchema } from './events.js'; import { WorkflowRunStatusSchema } from './runs.js'; import type { PaginatedResponse, PaginationOptions } from './shared.js'; diff --git a/packages/world/src/events.ts b/packages/world/src/events.ts index dbe441a9ee..1851d71124 100644 --- a/packages/world/src/events.ts +++ b/packages/world/src/events.ts @@ -1,4 +1,4 @@ -import * as z from 'zod'; +import { z } from 'zod'; import { AttributeChangesSchema } from './attributes.js'; import { getEventDataRefFields } from './event-metadata.js'; import type { Hook } from './hooks.js'; diff --git a/packages/world/src/hooks.ts b/packages/world/src/hooks.ts index ba6f27c972..2bc73c24a4 100644 --- a/packages/world/src/hooks.ts +++ b/packages/world/src/hooks.ts @@ -1,4 +1,4 @@ -import * as z from 'zod'; +import { z } from 'zod'; import { TraceCarrierSchema } from './queue.js'; import type { SerializedData } from './serialization.js'; import { SerializedDataSchema } from './serialization.js'; diff --git a/packages/world/src/queue.ts b/packages/world/src/queue.ts index 9a84469e3b..b79b95850d 100644 --- a/packages/world/src/queue.ts +++ b/packages/world/src/queue.ts @@ -1,4 +1,4 @@ -import * as z from 'zod'; +import { z } from 'zod/v4'; export type QueueKind = 'workflow'; diff --git a/packages/world/src/runs.ts b/packages/world/src/runs.ts index ad76e00926..9463dd7d0f 100644 --- a/packages/world/src/runs.ts +++ b/packages/world/src/runs.ts @@ -1,4 +1,4 @@ -import * as z from 'zod'; +import { z } from 'zod'; import { type SerializedData, SerializedDataSchema } from './serialization.js'; import type { PaginationOptions, ResolveData } from './shared.js'; diff --git a/packages/world/src/serialization.ts b/packages/world/src/serialization.ts index 04dec5d37b..e2218eb0b0 100644 --- a/packages/world/src/serialization.ts +++ b/packages/world/src/serialization.ts @@ -1,4 +1,4 @@ -import * as z from 'zod'; +import { z } from 'zod'; /** * Binary serialized data using devalue format. @@ -28,16 +28,7 @@ export const LegacySerializedDataSchemaV1: z.ZodType = z.compile( /** * Union schema that accepts both v2+ (Uint8Array) and legacy (any) serialized data. * Use this for validation when data may come from either specVersion. - * - * Serialized payloads are frequently absent: they travel in a frame body rather - * than the metadata object, or the event simply carries none. The `.optional()` - * is what allows the key to be missing at parse time. Before Zod 4.4 the - * `z.any()` branch did that implicitly, while the inferred type stayed - * required; newer Zod versions make `any`/`unknown` keys required at parse time - * too. The cast keeps the inferred type as it has always been, so consumers - * still see these keys as present, and only the parse-time tolerance is - * restored. */ export const SerializedDataSchema = z.compile( - z.union([BinarySerializedDataSchema, LegacySerializedDataSchemaV1]).optional() -) as unknown as z.ZodType; + z.union([BinarySerializedDataSchema, LegacySerializedDataSchemaV1]) +); diff --git a/packages/world/src/shared.test.ts b/packages/world/src/shared.test.ts index 01417122c7..4dd7a6df37 100644 --- a/packages/world/src/shared.test.ts +++ b/packages/world/src/shared.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import * as z from 'zod'; +import { z } from 'zod'; import { PaginatedResponseSchema } from './shared.js'; describe('PaginatedResponseSchema', () => { diff --git a/packages/world/src/shared.ts b/packages/world/src/shared.ts index 230c19c57c..d0712decf1 100644 --- a/packages/world/src/shared.ts +++ b/packages/world/src/shared.ts @@ -1,7 +1,5 @@ -import * as z from 'zod'; +import { z } from 'zod'; -// Zod 4.5 cannot compile recursive schemas. Keep this on the standard parser; -// wrapping it in z.compile() would silently return this same schema unchanged. export const zodJsonSchema: z.ZodType = z.lazy(() => { return z.union([ z.string(), diff --git a/packages/world/src/steps.ts b/packages/world/src/steps.ts index 85c4d9eb06..d1f08f2dcc 100644 --- a/packages/world/src/steps.ts +++ b/packages/world/src/steps.ts @@ -1,4 +1,4 @@ -import * as z from 'zod'; +import { z } from 'zod'; import { type SerializedData, SerializedDataSchema } from './serialization.js'; import type { PaginationOptions, ResolveData } from './shared.js'; diff --git a/packages/world/src/ulid.ts b/packages/world/src/ulid.ts index c5a3f398a8..bd4d27eac0 100644 --- a/packages/world/src/ulid.ts +++ b/packages/world/src/ulid.ts @@ -1,5 +1,5 @@ import { decodeTime } from 'ulid'; -import * as z from 'zod'; +import { z } from 'zod'; import { isSlotBody } from './slot-identity.js'; const UlidSchema = z.compile(z.string().ulid()); diff --git a/packages/world/src/waits.ts b/packages/world/src/waits.ts index 44f5900a81..ae0da6399f 100644 --- a/packages/world/src/waits.ts +++ b/packages/world/src/waits.ts @@ -1,4 +1,4 @@ -import * as z from 'zod'; +import { z } from 'zod'; export const WaitStatusSchema = z.compile(z.enum(['waiting', 'completed'])); diff --git a/workbench/example/workflows/100_durable_agent_e2e.ts b/workbench/example/workflows/100_durable_agent_e2e.ts index 1acaee918c..8c72eecbb6 100644 --- a/workbench/example/workflows/100_durable_agent_e2e.ts +++ b/workbench/example/workflows/100_durable_agent_e2e.ts @@ -4,7 +4,7 @@ import { DurableAgent } from '@workflow/ai/agent'; import { mockSequenceModel, mockTextModel } from '@workflow/ai/test'; import { FatalError, getWritable } from 'workflow'; -import * as z from 'zod'; +import z from 'zod/v4'; // ============================================================================ // Tool step functions diff --git a/workbench/example/workflows/4_ai.ts b/workbench/example/workflows/4_ai.ts index 692f10398b..3da39ef204 100644 --- a/workbench/example/workflows/4_ai.ts +++ b/workbench/example/workflows/4_ai.ts @@ -1,6 +1,6 @@ import { generateText, stepCountIs } from 'ai'; import { FatalError } from 'workflow'; -import * as z from 'zod'; +import z from 'zod/v4'; async function getWeatherInformation({ city }: { city: string }) { 'use step'; diff --git a/workbench/nextjs-turbopack/workflows/agent_chat.ts b/workbench/nextjs-turbopack/workflows/agent_chat.ts index 629a0c4552..001aed8ad7 100644 --- a/workbench/nextjs-turbopack/workflows/agent_chat.ts +++ b/workbench/nextjs-turbopack/workflows/agent_chat.ts @@ -5,7 +5,7 @@ import { type UIMessageChunk, } from 'ai'; import { getWritable } from 'workflow'; -import * as z from 'zod'; +import z from 'zod/v4'; // ============================================================================ // Tool step functions diff --git a/workbench/vitest/workflows/cookbook/child-workflows.ts b/workbench/vitest/workflows/cookbook/child-workflows.ts index eee9bdb446..b11d6f65b8 100644 --- a/workbench/vitest/workflows/cookbook/child-workflows.ts +++ b/workbench/vitest/workflows/cookbook/child-workflows.ts @@ -1,6 +1,6 @@ import { defineHook } from 'workflow'; import { start } from 'workflow/api'; -import * as z from 'zod'; +import { z } from 'zod'; async function processItem(item: string): Promise { 'use step'; From 6998502a0174302c63d39488d5c03fefca5b5891 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:29:29 -0700 Subject: [PATCH 08/13] Compile paginated response schemas Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- packages/world/src/shared.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/world/src/shared.ts b/packages/world/src/shared.ts index d0712decf1..e83c5d1aba 100644 --- a/packages/world/src/shared.ts +++ b/packages/world/src/shared.ts @@ -41,12 +41,14 @@ export type PageInfo = z.infer; export const PaginatedResponseSchema = ( dataSchema: T ) => - z.object({ - data: z.array(dataSchema), - cursor: z.string().nullable(), - hasMore: z.boolean(), - pageInfo: PageInfoSchema.optional(), - }); + z.compile( + z.object({ + data: z.array(dataSchema), + cursor: z.string().nullable(), + hasMore: z.boolean(), + pageInfo: PageInfoSchema.optional(), + }) + ); // Inferred type from schema export type PaginatedResponse = z.infer< From 697158656c97a0278a3dc1f38a26100c1aacbdf9 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:02:15 -0700 Subject: [PATCH 09/13] Fix Zod 4.5 Vercel event response parsing Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- .../world-vercel/src/events-batch.test.ts | 4 + packages/world-vercel/src/events-v4.ts | 78 +++++++++++++++++-- 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/packages/world-vercel/src/events-batch.test.ts b/packages/world-vercel/src/events-batch.test.ts index 6dcfa27b28..3c9ba21905 100644 --- a/packages/world-vercel/src/events-batch.test.ts +++ b/packages/world-vercel/src/events-batch.test.ts @@ -188,6 +188,10 @@ describe('createWorkflowRunEventBatch', () => { ); expect(result.results).toHaveLength(3); + expect(result.results[0]?.event).toEqual({ + ...completedEvent, + createdAt: new Date(CREATED_AT), + }); expect(requestBody).toBeDefined(); // biome-ignore lint/style/noNonNullAssertion: asserted above const frames = decodeBatchFrames(requestBody!); diff --git a/packages/world-vercel/src/events-v4.ts b/packages/world-vercel/src/events-v4.ts index f64e93ad0b..2fef8976b0 100644 --- a/packages/world-vercel/src/events-v4.ts +++ b/packages/world-vercel/src/events-v4.ts @@ -308,9 +308,71 @@ export interface PreconditionFailureDetails { cursor?: string; } +/** + * Event responses may omit an unresolved payload field entirely. Zod <=4.3 + * treated an object property backed by `z.any()` as optional, so EventSchema + * historically accepted that wire shape even though the property was not + * explicitly optional. Zod 4.5 correctly distinguishes a missing property + * from a present `undefined` value. + * + * Keep CreateEventSchema strict while preserving the Vercel response contract: + * temporarily materialize an omitted payload as `undefined` for EventSchema, + * then remove it from the parsed response again. + */ +const VercelEventWireSchema = z.compile( + z + .preprocess((value) => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return value; + } + + const event = value as Record; + const payloadField = + typeof event.eventType === 'string' + ? getEventDataPayloadField(event.eventType) + : undefined; + const eventData = event.eventData as Record | undefined; + if ( + !payloadField || + typeof eventData !== 'object' || + eventData === null || + Array.isArray(eventData) || + Object.hasOwn(eventData, payloadField) + ) { + return value; + } + + return { + ...event, + eventData: { + ...eventData, + [payloadField]: undefined, + }, + }; + }, EventSchema) + .transform((event) => { + const payloadField = getEventDataPayloadField(event.eventType); + if (!payloadField || !('eventData' in event)) return event; + + const eventData = event.eventData as Record | undefined; + if ( + typeof eventData !== 'object' || + eventData === null || + Array.isArray(eventData) || + eventData[payloadField] !== undefined + ) { + return event; + } + + const parsedEventData = { ...eventData }; + delete parsedEventData[payloadField]; + return { ...event, eventData: parsedEventData } as Event; + }) +); + const CreateEventV4BodyBaseSchema = z.compile( z.object({ - event: EventSchema, + event: VercelEventWireSchema, run: WorkflowRunSchema.optional(), step: StepWireSchema.transform(deserializeStep).optional(), hook: HookSchema.optional(), @@ -323,14 +385,14 @@ const CreateEventV4BodyBaseSchema = z.compile( const CreateEventV4PageSchema = z.compile( z.union([ z.object({ - events: z.array(EventSchema), + events: z.array(VercelEventWireSchema), cursor: z.string().nullable(), hasMore: z.boolean(), }), z.object({ - events: z.undefined(), - cursor: z.undefined(), - hasMore: z.undefined(), + events: z.undefined().optional(), + cursor: z.undefined().optional(), + hasMore: z.undefined().optional(), }), ]) ); @@ -431,13 +493,13 @@ function decodeLegacyStructuredError(payload: Uint8Array): unknown { function decodeEventFrame({ meta, body }: DecodedFrame): Event { const eventType = EventTypeSchema.parse(meta.eventType); - if (body.byteLength === 0) return EventSchema.parse(meta); + if (body.byteLength === 0) return VercelEventWireSchema.parse(meta); const payloadField = getEventDataPayloadField(eventType); assert(payloadField, `Event type ${eventType} cannot carry a payload body`); assert(meta.eventData && typeof meta.eventData === 'object'); - return EventSchema.parse({ + return VercelEventWireSchema.parse({ ...meta, eventData: { ...meta.eventData, @@ -647,7 +709,7 @@ function decodePreconditionDetails( const candidate = raw as Record; if (typeof candidate.eventId !== 'string') return undefined; if (hasUnusablePayload(candidate)) return undefined; - const event = EventSchema.safeParse(candidate); + const event = VercelEventWireSchema.safeParse(candidate); if (!event.success) return undefined; events.push(event.data); } From c3dc008ddc7efe0b42311fbfd8585f9c4c7f5cde Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:09:11 -0700 Subject: [PATCH 10/13] Preserve explicit undefined event payloads Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- packages/world-vercel/src/events-batch.test.ts | 14 +++++++++++++- packages/world-vercel/src/events-v4.ts | 9 +++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/world-vercel/src/events-batch.test.ts b/packages/world-vercel/src/events-batch.test.ts index 3c9ba21905..93888f9101 100644 --- a/packages/world-vercel/src/events-batch.test.ts +++ b/packages/world-vercel/src/events-batch.test.ts @@ -156,7 +156,14 @@ const stepB = { const fullSuccessBody = () => encode({ results: [ - { status: 200, event: completedEvent, step: stepA }, + { + status: 200, + event: { + ...completedEvent, + eventData: { ...completedEvent.eventData, result: undefined }, + }, + step: stepA, + }, { status: 200, event: createdEvent, step: { ...stepB } }, { status: 200, event: startedEvent, step: { ...stepB } }, ], @@ -191,6 +198,11 @@ describe('createWorkflowRunEventBatch', () => { expect(result.results[0]?.event).toEqual({ ...completedEvent, createdAt: new Date(CREATED_AT), + eventData: { ...completedEvent.eventData, result: undefined }, + }); + expect(result.results[1]?.event).toEqual({ + ...createdEvent, + createdAt: new Date(CREATED_AT), }); expect(requestBody).toBeDefined(); // biome-ignore lint/style/noNonNullAssertion: asserted above diff --git a/packages/world-vercel/src/events-v4.ts b/packages/world-vercel/src/events-v4.ts index 2fef8976b0..653ed0b8d8 100644 --- a/packages/world-vercel/src/events-v4.ts +++ b/packages/world-vercel/src/events-v4.ts @@ -316,9 +316,10 @@ export interface PreconditionFailureDetails { * from a present `undefined` value. * * Keep CreateEventSchema strict while preserving the Vercel response contract: - * temporarily materialize an omitted payload as `undefined` for EventSchema, - * then remove it from the parsed response again. + * temporarily materialize an omitted payload with a private sentinel for + * EventSchema, then remove only that synthesized value from the parsed response. */ +const OMITTED_EVENT_PAYLOAD = Symbol('omitted event payload'); const VercelEventWireSchema = z.compile( z .preprocess((value) => { @@ -346,7 +347,7 @@ const VercelEventWireSchema = z.compile( ...event, eventData: { ...eventData, - [payloadField]: undefined, + [payloadField]: OMITTED_EVENT_PAYLOAD, }, }; }, EventSchema) @@ -359,7 +360,7 @@ const VercelEventWireSchema = z.compile( typeof eventData !== 'object' || eventData === null || Array.isArray(eventData) || - eventData[payloadField] !== undefined + eventData[payloadField] !== OMITTED_EVENT_PAYLOAD ) { return event; } From 19adcf93974286e5ca6141aa1f674adf07fbe46f Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:14:09 -0700 Subject: [PATCH 11/13] Document event page schema intersection Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> --- packages/world-vercel/src/events-batch.test.ts | 4 ++-- packages/world-vercel/src/events-v4.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/world-vercel/src/events-batch.test.ts b/packages/world-vercel/src/events-batch.test.ts index 93888f9101..d13813721c 100644 --- a/packages/world-vercel/src/events-batch.test.ts +++ b/packages/world-vercel/src/events-batch.test.ts @@ -195,12 +195,12 @@ describe('createWorkflowRunEventBatch', () => { ); expect(result.results).toHaveLength(3); - expect(result.results[0]?.event).toEqual({ + expect(result.results[0]?.event).toStrictEqual({ ...completedEvent, createdAt: new Date(CREATED_AT), eventData: { ...completedEvent.eventData, result: undefined }, }); - expect(result.results[1]?.event).toEqual({ + expect(result.results[1]?.event).toStrictEqual({ ...createdEvent, createdAt: new Date(CREATED_AT), }); diff --git a/packages/world-vercel/src/events-v4.ts b/packages/world-vercel/src/events-v4.ts index 653ed0b8d8..f3a70a9c61 100644 --- a/packages/world-vercel/src/events-v4.ts +++ b/packages/world-vercel/src/events-v4.ts @@ -390,6 +390,8 @@ const CreateEventV4PageSchema = z.compile( cursor: z.string().nullable(), hasMore: z.boolean(), }), + // This schema is always intersected with CreateEventV4BodyBaseSchema. + // Keep it non-strict so the base response fields remain valid here. z.object({ events: z.undefined().optional(), cursor: z.undefined().optional(), From 2051ac8d0ac86d717d411ce8cbe58b1286ef1471 Mon Sep 17 00:00:00 2001 From: "vercel[bot]" <35613825+vercel[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:51:04 +0000 Subject: [PATCH 12/13] Fix: The legacy `v1Compat` path parses `/v1/runs/{id}/events` responses with the bare `EventSchema`, which under Zod 4.5 throws `SCHEMA_VALIDATION` when a `hook_received` response omits the required `payload` key, breaking hook resume for legacy spec-1 runs. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit fixes the issue reported at packages/world-vercel/src/events.ts:687 ## The bug In `packages/world-vercel/src/events.ts`, `createWorkflowRunEventInner`'s `v1Compat` catch-all POSTs to the legacy `/v1/runs/{id}/events` endpoint and validates the response with the **bare** `EventSchema`: ```ts const wireResult = await makeRequest({ endpoint: `/v1/runs/${encodeURIComponent(id)}/events`, options: { method: 'POST' }, data, config, schema: EventSchema, // <- bare, strict schema }); ``` `makeRequest` (`packages/world-vercel/src/utils.ts`) runs `schema.safeParse(parseResult.data)` and throws a `WorkflowWorldError` with code `SCHEMA_VALIDATION` on failure. Under Zod ~4.5, an object property backed by `z.any()` / a union-with-`z.any()` is **no longer implicitly optional** — a *missing* key fails parse. `HookReceivedEventSchema` declares `payload: SerializedDataSchema` as a **required** key, and `SerializedDataSchema` is `z.union([BinarySerializedDataSchema, LegacySerializedDataSchemaV1(z.any())])`. Commits `6971586` / `c3dc008` / `19adcf9` ("Fix Zod 4.5 Vercel event response parsing") fixed this regression for every v4 site by introducing `VercelEventWireSchema` in `events-v4.ts` — a preprocess/transform wrapper that materializes an omitted payload as a private `OMITTED_EVENT_PAYLOAD` sentinel before `EventSchema.parse`, then strips only that synthesized value again. It replaced `EventSchema` at the three v4 decode call sites and in `CreateEventV4BodyBaseSchema` / `CreateEventV4PageSchema` — **but the `v1Compat` catch-all in `events.ts` was left on bare `EventSchema`.** ## Reachability * `resumeHook(token, payload)` accepts any payload, including `undefined`. For legacy runs it takes the `v1Compat` path (`isLegacySpecVersion(hook.specVersion)`), dehydrates via the JSON `revive(stringify(...))` branch, and posts `eventData: { payload: dehydratedPayload }`. * When the resume payload is `undefined` (a signal-only hook / empty webhook body), the legacy v1 server has no payload value to echo back, so its JSON response's `eventData` omits the `payload` key entirely — exactly the "event responses may omit an unresolved payload field" contract that motivated `VercelEventWireSchema`. * The response is then parsed with bare `EventSchema`, which now throws `SCHEMA_VALIDATION`. This error is not a `HookNotFoundError`/`RunExpiredError`, so resume-hook rethrows it and the hook resume fails. Confirmed the new regression test (`hook_received` response with `eventData: {}`) fails against bare `EventSchema` and passes against `VercelEventWireSchema`. ## The fix * Exported `VercelEventWireSchema` from `events-v4.ts`. * Imported it into `events.ts` and swapped `schema: EventSchema` → `schema: VercelEventWireSchema` in the `v1Compat` catch-all, matching every v4 site. Removed the now-unused `EventSchema` import. * Added a regression test asserting the `v1Compat` path parses a `hook_received` response whose `eventData` omits `payload`. `pnpm typecheck` is clean and all 52 tests in `events.test.ts` pass. Co-authored-by: Vercel Co-authored-by: VaguelySerious --- packages/world-vercel/src/events-v4.ts | 6 +++- packages/world-vercel/src/events.test.ts | 43 ++++++++++++++++++++++++ packages/world-vercel/src/events.ts | 9 +++-- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/packages/world-vercel/src/events-v4.ts b/packages/world-vercel/src/events-v4.ts index f3a70a9c61..6088954b71 100644 --- a/packages/world-vercel/src/events-v4.ts +++ b/packages/world-vercel/src/events-v4.ts @@ -318,9 +318,13 @@ export interface PreconditionFailureDetails { * Keep CreateEventSchema strict while preserving the Vercel response contract: * temporarily materialize an omitted payload with a private sentinel for * EventSchema, then remove only that synthesized value from the parsed response. + * + * Exported so the legacy `/v1/runs/:id/events` path (see `events.ts` + * `createWorkflowRunEventInner` v1Compat catch-all) can parse its event + * responses with the same omitted-payload tolerance the v4 sites use. */ const OMITTED_EVENT_PAYLOAD = Symbol('omitted event payload'); -const VercelEventWireSchema = z.compile( +export const VercelEventWireSchema = z.compile( z .preprocess((value) => { if (typeof value !== 'object' || value === null || Array.isArray(value)) { diff --git a/packages/world-vercel/src/events.test.ts b/packages/world-vercel/src/events.test.ts index 8689afff7f..1ec1ccce24 100644 --- a/packages/world-vercel/src/events.test.ts +++ b/packages/world-vercel/src/events.test.ts @@ -162,6 +162,49 @@ describe('createWorkflowRunEvent with v1Compat', () => { agent.assertNoPendingInterceptors(); }); + // A `hook_received` resumed with a payload the legacy v1 server does not + // echo back (e.g. an `undefined` resume payload) comes back with an + // `eventData` that omits the required `payload` key. Under Zod 4.5 the bare + // `EventSchema` rejects a missing property, so this path must parse with the + // omitted-payload-tolerant wire schema (the same fix the v4 sites use), or + // hook resume breaks for legacy spec-1 runs. + it('parses a legacy hook_received response that omits payload', async () => { + const agent = mockAgent(); + agent + .get(ORIGIN) + .intercept({ path: '/api/v1/runs/wrun_legacy/events', method: 'POST' }) + .reply( + 200, + { + eventId: 'evnt_legacy', + runId: 'wrun_legacy', + eventType: 'hook_received', + correlationId: 'hook_1', + createdAt: '2026-06-10T00:00:00.000Z', + specVersion: 1, + // payload key intentionally absent + eventData: {}, + }, + { headers: { 'content-type': 'application/json' } } + ); + + const result = await createWorkflowRunEvent( + 'wrun_legacy', + { + eventType: 'hook_received', + correlationId: 'hook_1', + specVersion: 1, + eventData: { payload: undefined }, + } as AnyEventRequest, + { v1Compat: true }, + { token: 'test-token', dispatcher: agent } + ); + + expect(result.event?.eventId).toBe('evnt_legacy'); + expect(result.event?.eventType).toBe('hook_received'); + agent.assertNoPendingInterceptors(); + }); + it('rejects v1Compat without a runId for non-lifecycle events', async () => { await expect( createWorkflowRunEvent( diff --git a/packages/world-vercel/src/events.ts b/packages/world-vercel/src/events.ts index 7d373a42d2..73bd19b37f 100644 --- a/packages/world-vercel/src/events.ts +++ b/packages/world-vercel/src/events.ts @@ -43,7 +43,6 @@ import { type EventBatchResult, type EventDataPayloadField, type EventResult, - EventSchema, type GetEventParams, getEventDataPayloadField, isHookEventRequiringExistence, @@ -63,6 +62,7 @@ import { getEventV4, getWorkflowRunEventsV4, type ListEventsV4Params, + VercelEventWireSchema, } from './events-v4.js'; import { decode as decodeRunId } from './run-id/index.js'; import { cancelWorkflowRunV1, createWorkflowRunV1 } from './runs.js'; @@ -684,7 +684,12 @@ async function createWorkflowRunEventInner( options: { method: 'POST' }, data, config, - schema: EventSchema, + // Match the v4 sites: parse legacy event responses with the + // omitted-payload-tolerant wire schema. A `hook_received` response can + // omit the required `payload` key (e.g. a resume with an `undefined` + // payload the server never echoes back), which bare `EventSchema.parse` + // now rejects under Zod 4.5. + schema: VercelEventWireSchema, }); return { event: wireResult }; } From 388ea88a8e5d5148a230b626b9beda495c09e5c2 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Thu, 10 Sep 2026 16:36:47 -0700 Subject: [PATCH 13/13] Fix zero-retention purge read-back under Zod 4.5 Merging main (which added the world-local retention/purge feature) into this Zod 4.5 branch surfaced a real behavior change: Zod 4.5 rejects a required object key that is entirely absent, even when the field's own schema (a union ending in z.any()) would accept `undefined` as a value. Zod 4.3.6 tolerated the missing key. world-local's zero-retention purge deletes an event's payload ref field (input/result/error/payload) outright rather than writing it back as an explicit `undefined`. Under Zod 4.5 this makes `run_created`/`step_created`/`step_completed`/etc. events fail EventSchema validation after a purge, and paginatedFileSystemQuery silently drops them, corrupting storage.events.list() for any zero-retention run (packages/world-local/src/storage/run-retention.test.ts "drops every event payload but keeps the log" caught this). Rather than loosening the shared @workflow/world EventSchema (which also backs the create-time contract used by world-vercel, e.g. run_created requiring input when the run is actually being created), add a world-local-local ReadEventSchema that restores a purged ref field as an explicit `undefined` before delegating to EventSchema. Every read of a stored event in events-storage.ts now goes through it. Separately, world-postgres's retention.test.ts seeds a step_completed event with `eventData: { output }`, but the schema (and storage.ts's own step-completion handler) expects `result`. This slipped past Zod 4.3.6 for the same missing-required-key reason and broke all 16 tests in that file under Zod 4.5. Fixed the seed to use `result`. Co-Authored-By: Claude Sonnet 5 --- .../world-local/src/storage/events-storage.ts | 22 +++++----- .../world-local/src/storage/run-retention.ts | 41 ++++++++++++++++++- .../world-postgres/test/retention.test.ts | 2 +- 3 files changed, 52 insertions(+), 13 deletions(-) diff --git a/packages/world-local/src/storage/events-storage.ts b/packages/world-local/src/storage/events-storage.ts index c009b80472..5b2a4e6c05 100644 --- a/packages/world-local/src/storage/events-storage.ts +++ b/packages/world-local/src/storage/events-storage.ts @@ -29,7 +29,6 @@ import type { } from '@workflow/world'; import { applyAttributeChanges, - EventSchema, eventIdToSlot, FIRST_EVENT_SLOT, getMaxEventsPerRun, @@ -107,6 +106,7 @@ import { handleLegacyEvent } from './legacy.js'; import { purgeRunEntityData, purgesUserDataOnFinish, + ReadEventSchema, withRunPayloadsPurged, } from './run-retention.js'; import { signalRunTerminal } from './run-status-signal.js'; @@ -241,7 +241,7 @@ async function findCommittedResumeEvent( basedir, 'events', `${runId}-${eventId}`, - EventSchema, + ReadEventSchema, tag ); if ( @@ -365,7 +365,7 @@ async function findExistingHookCreatedEventId( ): Promise { const result = await paginatedFileSystemQuery({ directory: path.join(basedir, 'events'), - schema: EventSchema, + schema: ReadEventSchema, filePrefix: `${runId}-`, filter: (event) => event.eventType === 'hook_created' && @@ -409,7 +409,7 @@ async function repairHookEntityFromPersistedEvent( basedir, 'events', compositeKey, - EventSchema, + ReadEventSchema, tag ); if ( @@ -855,7 +855,7 @@ export function createEventsStorage( return; } - const cachedEvent = EventSchema.safeParse( + const cachedEvent = ReadEventSchema.safeParse( JSON.parse(serializedEvent, jsonReviver) ); if (cachedEvent.success) { @@ -909,7 +909,7 @@ export function createEventsStorage( const queryRunEvents = (runId: string, pagination: PaginationOptions) => paginatedFileSystemQuery({ directory: path.join(basedir, 'events'), - schema: EventSchema, + schema: ReadEventSchema, cachedItems: eventCache, filePrefix: `${runId}-`, sortOrder: pagination.sortOrder ?? 'asc', @@ -1393,7 +1393,7 @@ export function createEventsStorage( basedir, 'events', `${effectiveRunId}-${committedClaim.eventId}`, - EventSchema, + ReadEventSchema, tag ); const committedEvent = @@ -1486,7 +1486,7 @@ export function createEventsStorage( basedir, 'events', `${effectiveRunId}-${claim.eventId}`, - EventSchema, + ReadEventSchema, tag ); if (atClaimedId && isResumeEvent(atClaimedId, claim)) { @@ -2899,7 +2899,7 @@ export function createEventsStorage( basedir, 'events', `${effectiveRunId}-${eventId}`, - EventSchema, + ReadEventSchema, tag ); if ( @@ -3112,7 +3112,7 @@ export function createEventsStorage( basedir, 'events', compositeKey, - EventSchema, + ReadEventSchema, tag ); if (!event) { @@ -3151,7 +3151,7 @@ export function createEventsStorage( const resolveData = params.resolveData ?? DEFAULT_RESOLVE_DATA_OPTION; const result = await paginatedFileSystemQuery({ directory: path.join(basedir, 'events'), - schema: EventSchema, + schema: ReadEventSchema, cachedItems: eventCache, // Scoped to the run's own event files, since a correlation id // identifies a step or wait only within its run: a slot-numbered diff --git a/packages/world-local/src/storage/run-retention.ts b/packages/world-local/src/storage/run-retention.ts index 8a5a14e1f0..ac77bbd0c8 100644 --- a/packages/world-local/src/storage/run-retention.ts +++ b/packages/world-local/src/storage/run-retention.ts @@ -1,6 +1,7 @@ import path from 'node:path'; -import type { WorkflowRun } from '@workflow/world'; +import type { Event, WorkflowRun } from '@workflow/world'; import { + EventSchema, getEventDataRefFields, RETENTION_ATTRIBUTE, readRunRetention, @@ -110,6 +111,44 @@ export async function purgeRunEntityData( */ const RawEntitySchema = z.record(z.string(), z.any()); +/** + * `EventSchema`, tolerant of a zero-retention purge having deleted an + * event's ref field (`eventData.input`/`result`/`error`/`payload`, see + * {@link getEventDataRefFields}) outright. + * + * `scrubEntityFiles` above deletes the key rather than writing it back as an + * explicit `undefined`, because JSON has no way to represent "present but + * undefined" and a schema round-trip would drop it either way. A *missing* + * key and a key *present with value `undefined`* are different things to + * Zod, though: a required field whose own schema tolerates `undefined` + * (`SerializedDataSchema`'s trailing `z.any()`) only accepts the latter. + * Every read of a stored event goes through this schema instead of the bare + * `EventSchema` so a purged run's log stays parseable, while `EventSchema` + * itself stays strict for the create-time contract (`CreateEventSchema` + * shares the same per-type schemas, and a run genuinely being created must + * still supply `input`). + */ +export const ReadEventSchema: z.ZodType = z.preprocess((raw) => { + if ( + raw && + typeof raw === 'object' && + 'eventType' in raw && + 'eventData' in raw + ) { + const eventData = (raw as { eventData: unknown }).eventData; + if (eventData && typeof eventData === 'object') { + for (const field of getEventDataRefFields( + String((raw as { eventType: unknown }).eventType) + )) { + if (!(field in eventData)) { + (eventData as Record)[field] = undefined; + } + } + } + } + return raw; +}, EventSchema); + /** Rewrite every file in `directory` belonging to `runId` with `scrub` applied. */ async function scrubEntityFiles( directory: string, diff --git a/packages/world-postgres/test/retention.test.ts b/packages/world-postgres/test/retention.test.ts index c7471d5817..8616e52adb 100644 --- a/packages/world-postgres/test/retention.test.ts +++ b/packages/world-postgres/test/retention.test.ts @@ -117,7 +117,7 @@ describe('Retention ($retention: 0)', () => { await events.create(runId, { eventType: 'step_completed', correlationId: stepId, - eventData: { output: new Uint8Array([6, 7]) }, + eventData: { result: new Uint8Array([6, 7]) }, }); const hookId = `hook_${ulid()}`;