migrating-workflow-v4-to-v5

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Migrating Workflow SDK 4.x to 5.0

将 Workflow SDK 从 4.x 迁移到 5.0

Workflow SDK 5.0 keeps the programming model from 4.x.
"use workflow"
/
"use step"
,
start()
,
getRun()
, hooks, webhooks, streams,
sleep()
, retries, and the event log are unchanged, so most application code compiles as-is.
The breaking changes are concentrated in three places:
  1. Runtime entrypoints (
    workflow/api
    ,
    workflow/runtime
    ) — two exports removed.
  2. The
    World
    interface
    — only relevant if the app implements a custom World or calls
    getWorld()
    directly.
  3. Build integrations —
    @workflow/nest
    subpaths, and private compiler subpaths that were never public.
Do not rewrite workflow or step bodies. If you find yourself restructuring business logic, you have gone outside this migration.
Workflow SDK 5.0 保留了 4.x 的编程模型。
"use workflow"
/
"use step"
、
start()
、
getRun()
、hooks、webhooks、streams、
sleep()
、重试以及事件日志均未改变,因此大多数应用代码可以原样编译。
破坏性变更集中在三个方面:
  1. 运行时入口(
    workflow/api
    、
    workflow/runtime
    )——移除了两个导出。
  2. World
    接口
    ——仅当应用实现了自定义 World 或直接调用
    getWorld()
    时才相关。
  3. 构建集成——
    @workflow/nest
    子路径,以及从未公开的编译器私有子路径。
不要重写 workflow 或 step 的函数体。如果你发现自己在重构业务逻辑,那已经超出了本次迁移的范围。

Intake

信息收集

Before editing, establish:
  1. Which packages are installed. Read
    package.json
    for
    workflow
    and every
    @workflow/*
    dependency.
  2. Whether the app touches the runtime. Grep for
    getWorld
    ,
    createWorld
    ,
    getWorldHandlers
    ,
    writeToStream
    ,
    readFromStream
    ,
    closeStream
    ,
    listStreamsByRunId
    ,
    getStreamChunks
    ,
    world.steps
    ,
    listByCorrelationId
    ,
    runStep
    ,
    stepEntrypoint
    ,
    internal/private
    ,
    core/private
    .
  3. Whether the app implements a custom World. Grep for
    implements World
    ,
    : World
    ,
    createLocalWorld
    ,
    createVercelWorld
    ,
    startWorkflowWorld
    .
  4. Which framework integration is in use.
    @workflow/next
    ,
    @workflow/nest
    ,
    @workflow/nitro
    ,
    @workflow/sveltekit
    ,
    @workflow/vite
    ,
    @workflow/nuxt
    ,
    @workflow/astro
    , or the CLI.
  5. Whether
    hook.getConflict()
    is used.
    Grep for
    getConflict
    .
  6. Whether hook metadata is read outside a workflow. Grep for
    getHookByToken
    and
    resumeHook
    , then for
    .metadata
    on their results.
  7. Whether the app calls the compiler directly. Grep for
    mode: 'client'
    ,
    transformSync
    ,
    swc-plugin-workflow
    . Only custom build integrations do this.
  8. Whether
    experimental_setAttributes
    is used.
    Grep for
    experimental_setAttributes
    .
Report anything in 2–8 that the app does not use as "not applicable" rather than silently skipping it.
在编辑之前,先确认:
  1. 安装了哪些包。 读取
    package.json
    中的
    workflow
    和所有
    @workflow/*
    依赖。
  2. 应用是否触及运行时。 搜索
    getWorld
    、
    createWorld
    、
    getWorldHandlers
    、
    writeToStream
    、
    readFromStream
    、
    closeStream
    、
    listStreamsByRunId
    、
    getStreamChunks
    、
    world.steps
    、
    listByCorrelationId
    、
    runStep
    、
    stepEntrypoint
    、
    internal/private
    、
    core/private
    。
  3. 应用是否实现了自定义 World。 搜索
    implements World
    、
    : World
    、
    createLocalWorld
    、
    createVercelWorld
    、
    startWorkflowWorld
    。
  4. 使用的是哪个框架集成。
    @workflow/next
    、
    @workflow/nest
    、
    @workflow/nitro
    、
    @workflow/sveltekit
    、
    @workflow/vite
    、
    @workflow/nuxt
    、
    @workflow/astro
    或 CLI。
  5. 是否使用了
    hook.getConflict()
    。
    搜索
    getConflict
    。
  6. 是否在 workflow 外部读取 hook 元数据。 搜索
    getHookByToken
    和
    resumeHook
    ,然后检查它们结果上的
    .metadata
    。
  7. 应用是否直接调用编译器。 搜索
    mode: 'client'
    、
    transformSync
    、
    swc-plugin-workflow
    。只有自定义构建集成才会这样做。
  8. 是否使用了
    experimental_setAttributes
    。
    搜索
    experimental_setAttributes
    。
对于 2–8 中应用未使用的内容,请报告为“不适用”,而不是静默跳过。

Step 1 — bump the dependencies

步骤 1 — 升级依赖

Move every
workflow
and
@workflow/*
dependency to
^5.0.0
. They are released together and must not be mixed across majors — a 4.x
@workflow/next
against a 5.x
workflow
will fail at build time.
json
{
  "dependencies": {
    "workflow": "^5.0.0",
    "@workflow/next": "^5.0.0"
  }
}
Then reinstall and rebuild so the compiler regenerates the workflow/step bundles and the generated routes under
.well-known/workflow/v1/
. Never hand-edit generated output.
Node requirements are unchanged:
^18 || ^20 || ^22 || ^24
.
将所有
workflow
和
@workflow/*
依赖升级到
^5.0.0
。它们是一起发布的,不能跨大版本混用——4.x 的
@workflow/next
搭配 5.x 的
workflow
会在构建时失败。
json
{
  "dependencies": {
    "workflow": "^5.0.0",
    "@workflow/next": "^5.0.0"
  }
}
然后重新安装并重新构建,让编译器重新生成 workflow/step bundles 以及
.well-known/workflow/v1/
下的生成路由。切勿手动编辑生成输出。
Node 版本要求不变:
^18 || ^20 || ^22 || ^24
。

Step 2 — apply the mechanical rewrites

步骤 2 — 应用机械重写

Apply each rule only where the pattern actually appears.
仅在模式实际出现的位置应用每条规则。

getWorld()
and
createWorld()
are async

getWorld()
和
createWorld()
现在是异步的

ts
// v4
const world = getWorld();

// v5
const world = await getWorld();
This also applies to
getWorldHandlers()
. Awaiting was already correct in 4.x, so this edit is safe to make before the dependency bump. Propagate
async
up the call chain rather than wrapping in
.then()
chains.
ts
// v4
const world = getWorld();

// v5
const world = await getWorld();
这也适用于
getWorldHandlers()
。在 4.x 中
await
已经是正确的,因此这个修改可以在依赖升级之前安全进行。在调用链上向上传播
async
,而不是用
.then()
链包裹。

createLocalWorld()
and
createVercelWorld()
removed

createLocalWorld()
和
createVercelWorld()
已移除

First-party World packages now expose a single
createWorld()
factory. The arguments are unchanged — this is a rename only.
ts
// v4
import { createLocalWorld } from '@workflow/world-local';
const world = createLocalWorld({ dataDir });

// v5
import { createWorld } from '@workflow/world-local';
const world = createWorld({ dataDir });
The same applies to
createVercelWorld
from
@workflow/world-vercel
.
第一方 World 包现在暴露单一的
createWorld()
工厂。参数不变——这仅是重命名。
ts
// v4
import { createLocalWorld } from '@workflow/world-local';
const world = createLocalWorld({ dataDir });

// v5
import { createWorld } from '@workflow/world-local';
const world = createWorld({ dataDir });
@workflow/world-vercel
中的
createVercelWorld
同理。

runStep
removed from
workflow/api

runStep
已从
workflow/api
移除

Call the step function directly. The compiler routes the call through the step runtime.
ts
// v4
import { runStep } from 'workflow/api';
const result = await runStep(chargeCard, [orderId]);

// v5
const result = await chargeCard(orderId);
直接调用 step 函数。编译器会将调用路由到 step 运行时。
ts
// v4
import { runStep } from 'workflow/api';
const result = await runStep(chargeCard, [orderId]);

// v5
const result = await chargeCard(orderId);

stepEntrypoint
removed from
workflow/runtime

stepEntrypoint
已从
workflow/runtime
移除

Framework integrations generate step routes themselves — delete hand-written step routes that existed only to call
stepEntrypoint
. For a custom host, serve the handlers from
getWorldHandlers()
instead.
框架集成会自行生成 step 路由——删除那些仅为调用
stepEntrypoint
而存在的手写 step 路由。对于自定义 host,请改为从
getWorldHandlers()
提供 handlers。

workflow/internal/private
and
@workflow/core/private
removed

workflow/internal/private
和
@workflow/core/private
已移除

These subpaths were never public API. Remove the imports; if generated build output still references them, it is stale — reinstall and rebuild rather than restoring the imports.
这些子路径从来不是公共 API。删除这些导入;如果生成的构建输出仍引用它们,说明输出已过期——重新安装并重新构建,而不是恢复导入。

Stream methods moved to
world.streams.*
with
runId
first

Stream 方法移至
world.streams.*
,且
runId
放在第一位

v4v5
world.writeToStream(name, runId, chunk)
world.streams.write(runId, name, chunk)
world.writeToStreamMulti(name, runId, chunks)
world.streams.writeMulti(runId, name, chunks)
world.closeStream(name, runId)
world.streams.close(runId, name)
world.readFromStream(name, startIndex?)
world.streams.get(runId, name, startIndex?)
world.getStreamChunks(name, runId, options?)
world.streams.getChunks(runId, name, options?)
world.listStreamsByRunId(runId)
world.streams.list(runId)
The argument order flipped, so a rename alone silently passes a stream name where a run ID is expected. Swap the arguments at every call site.
readFromStream
had no
runId
parameter at all —
streams.get
requires one, so thread the owning run ID through to the call.
Application code that uses
getWritable()
inside a workflow or reads
run.readable
is unaffected; this rule is only for direct
World
access.
v4v5
world.writeToStream(name, runId, chunk)
world.streams.write(runId, name, chunk)
world.writeToStreamMulti(name, runId, chunks)
world.streams.writeMulti(runId, name, chunks)
world.closeStream(name, runId)
world.streams.close(runId, name)
world.readFromStream(name, startIndex?)
world.streams.get(runId, name, startIndex?)
world.getStreamChunks(name, runId, options?)
world.streams.getChunks(runId, name, options?)
world.listStreamsByRunId(runId)
world.streams.list(runId)
参数顺序颠倒了,因此仅重命名会在期望 run ID 的位置静默传入 stream 名称。在每个调用点交换参数。
readFromStream
原本根本没有
runId
参数——
streams.get
需要一个,因此需要将所属 run ID 传递到调用处。
在 workflow 内使用
getWritable()
或读取
run.readable
的应用代码不受影响;此规则仅适用于直接访问
World
。

world.steps.get()
requires a
runId

world.steps.get()
需要
runId

The first parameter was
string | undefined
and is now
string
. Pass the run ID that owns the step.
ts
// v4
const step = await world.steps.get(undefined, stepId);

// v5
const step = await world.steps.get(runId, stepId);
第一个参数原本是
string | undefined
,现在是
string
。传入拥有该 step 的 run ID。
ts
// v4
const step = await world.steps.get(undefined, stepId);

// v5
const step = await world.steps.get(runId, stepId);

events.listByCorrelationId()
requires a
runId

events.listByCorrelationId()
需要
runId

A correlation ID identifies a step, hook or wait within its run, not across runs. The lookup is scoped to one run, so pass the run that owns the ID. The same applies to
analytics.events.listByCorrelationId()
.
ts
// v4
const events = await world.events.listByCorrelationId({ correlationId });

// v5
const events = await world.events.listByCorrelationId({ runId, correlationId });
correlation ID 标识的是某个 run 内的 step、hook 或 wait,而不是跨 run。查询被限定在单个 run 内,因此传入拥有该 ID 的 run。
analytics.events.listByCorrelationId()
同理。
ts
// v4
const events = await world.events.listByCorrelationId({ correlationId });

// v5
const events = await world.events.listByCorrelationId({ runId, correlationId });

hook.getConflict()
resolves with a
Run

hook.getConflict()
解析为一个
Run

The resolved value is now the conflicting run handle rather than
{ runId }
.
conflict.runId
still reads the same, so existing code keeps working — but the round trip through a step to fetch the run can be deleted.
ts
// v4
const conflict = await hook.getConflict();
if (conflict) {
  const run = await fetchRun(conflict.runId); // "use step" wrapper around getRun()
  return { dedupedTo: await run.returnValue };
}

// v5
const conflict = await hook.getConflict();
if (conflict) {
  return { dedupedTo: await conflict.returnValue };
}
await conflict.status
and
await conflict.cancel()
are available on the same handle. Do not remove the
if (conflict)
null check —
getConflict()
still resolves
null
when the token was claimed cleanly.
解析值现在是冲突的 run handle,而不是
{ runId }
。
conflict.runId
仍然可以读取同样的值,因此现有代码可以继续工作——但通过 step 往返获取 run 的代码可以删除。
ts
// v4
const conflict = await hook.getConflict();
if (conflict) {
  const run = await fetchRun(conflict.runId); // "use step" wrapper around getRun()
  return { dedupedTo: await run.returnValue };
}

// v5
const conflict = await hook.getConflict();
if (conflict) {
  return { dedupedTo: await conflict.returnValue };
}
同一个 handle 上可以使用
await conflict.status
和
await conflict.cancel()
。不要移除
if (conflict)
空值检查——当 token 被正常认领时,
getConflict()
仍会解析为
null
。

hook.metadata
is a Promise

hook.metadata
是一个 Promise

On the hook returned by
getHookByToken()
and
resumeHook()
,
metadata
is now a getter that returns a memoized Promise, the same shape as
run.returnValue
. Looking a hook up by token is a single read; hydrating the metadata can add network round trips, and those are paid only by code that awaits it.
ts
// v4
const hook = await getHookByToken(token);
if (hook.metadata?.allowedUserId !== userId) {
  return new Response('Forbidden', { status: 403 });
}

// v5
const hook = await getHookByToken(token);
const metadata = (await hook.metadata) as { allowedUserId?: string } | undefined;
if (metadata?.allowedUserId !== userId) {
  return new Response('Forbidden', { status: 403 });
}
Awaiting it on a hook that stored no metadata resolves
undefined
with no I/O, so the
await
needs no guard.
{ ...hook }
and
JSON.stringify(hook)
no longer carry
metadata
, so a handler that forwarded the whole hook object must forward the awaited value explicitly.
hook.runId
,
hook.token
, and the other fields are unchanged, and
world.hooks.getByToken()
still returns the raw
Hook
record. Under TypeScript the old shape fails the build; in plain JavaScript
hook.metadata.allowedUserId
reads
undefined
off a Promise, so a check like the one above silently rejects every request.
在
getHookByToken()
和
resumeHook()
返回的 hook 上,
metadata
现在是一个 getter,返回一个带记忆化的 Promise,形状与
run.returnValue
相同。按 token 查找 hook 是一次读取;填充 metadata 可能会增加网络往返,而这些开销只会由
await
它的代码承担。
ts
// v4
const hook = await getHookByToken(token);
if (hook.metadata?.allowedUserId !== userId) {
  return new Response('Forbidden', { status: 403 });
}

// v5
const hook = await getHookByToken(token);
const metadata = (await hook.metadata) as { allowedUserId?: string } | undefined;
if (metadata?.allowedUserId !== userId) {
  return new Response('Forbidden', { status: 403 });
}
在未存储 metadata 的 hook 上
await
它会无 I/O 地解析为
undefined
,因此
await
不需要防护。
{ ...hook }
和
JSON.stringify(hook)
不再包含
metadata
,因此转发整个 hook 对象的 handler 必须显式转发 await 后的值。
hook.runId
、
hook.token
以及其他字段不变,
world.hooks.getByToken()
仍然返回原始的
Hook
记录。在 TypeScript 下,旧形状会导致构建失败;在纯 JavaScript 中,
hook.metadata.allowedUserId
会从 Promise 上读取到
undefined
,因此像上面这样的检查会静默拒绝所有请求。

experimental_setAttributes
renamed to
setAttributes

experimental_setAttributes
重命名为
setAttributes

ts
// v4
import { experimental_setAttributes } from 'workflow';

// v5
import { setAttributes } from 'workflow';
The old name was removed in v5, so this rewrite is required.
ExperimentalSetAttributesOptions
is likewise now
SetAttributesOptions
. The
attributes
option on
start()
is unchanged.
ts
// v4
import { experimental_setAttributes } from 'workflow';

// v5
import { setAttributes } from 'workflow';
旧名称已在 v5 中移除,因此这个重写是必需的。
ExperimentalSetAttributesOptions
同样变成了
SetAttributesOptions
。
start()
上的
attributes
选项不变。

mode: 'client'
removed from the SWC transform

SWC transform 中移除了
mode: 'client'

Only relevant to a custom build integration that calls the compiler itself. The
client
mode merged into
step
, which now absorbs hoisted variable references and dead-code elimination. Pass
mode: 'step'
.
仅与直接调用编译器的自定义构建集成相关。
client
模式已合并到
step
中,后者现在会吸收提升的变量引用并执行死代码消除。请传入
mode: 'step'
。

NestLocalBuilder
moved out of the
@workflow/nest
root

NestLocalBuilder
已移出
@workflow/nest
根路径

ts
// v4
import { NestLocalBuilder } from '@workflow/nest';

// v5
import { NestLocalBuilder } from 'workflow/nest/builder';
NestVercelBuilder
lives at
workflow/nest/vercel-builder
.
WorkflowModule
still comes from
@workflow/nest
; the split keeps the build toolchain out of the runtime bundle, so do not re-export the builder from a module that runtime code imports.
ts
// v4
import { NestLocalBuilder } from '@workflow/nest';

// v5
import { NestLocalBuilder } from 'workflow/nest/builder';
NestVercelBuilder
位于
workflow/nest/vercel-builder
。
WorkflowModule
仍然来自
@workflow/nest
;拆分是为了让构建工具链不进入运行时 bundle,因此不要从运行时代码导入的模块中重新导出 builder。

Step 3 — flag the behavior changes that need a decision

步骤 3 — 标出需要决策的行为变更

These are not code edits. Report each one that applies, and do not "fix" them silently.
  • Tracing defaults to
    linked
    .
    Each workflow and step invocation is its own trace root with span links to the enqueue site and the run origin, instead of one trace per run. If the app has dashboards, saved queries, or alerts keyed on a single trace ID per run, either move them to the
    workflow.run.id
    attribute or set
    WORKFLOW_TRACE_MODE=continuous
    to restore the 4.x shape.
  • The event-creation precondition guard is gone.
    WORKFLOW_PRECONDITION_GUARD
    no longer exists; remove it from the app's environment and deployment config if it is set. No World in the SDK rejects a write for a stale snapshot any more — a replay that is behind learns what it missed from the write it makes next.
  • Turbo mode is on by default. The first invocation of a run backgrounds
    run_started
    and skips the initial event-log load.
    WORKFLOW_TURBO=0
    disables it.
  • Errors keep their type.
    WorkflowRunFailedError.cause
    now preserves the original class identity and cause chain. Code that pattern-matched on
    error.message
    because the class was flattened in 4.x can use
    instanceof
    — but flag it rather than rewriting error handling unprompted.
  • Event IDs are slot numbers, not ULIDs. An event ID is now its 1-based position in the run's log (
    evnt_00000000000000000000000042
    ), unique only within a run and carrying no timestamp. Report any app code that uses an event ID as a global key (pair it with the
    runId
    ) or decodes a time out of one (read
    createdAt
    off the event). Other entity IDs are unchanged.
  • A per-run event limit is enforced. The World supplies the ceiling (25,000 events on the Local and Vercel Worlds) and a run that reaches it fails with
    MAX_EVENTS_EXCEEDED
    . Flag any workflow with an unbounded loop; the fix is a child run per batch, which is a design change, not a migration edit.
  • Stream writes flush the leading chunk immediately. The flush window default went from 10ms to 0. An app that relied on the window to coalesce a burst of tiny chunks can set
    streamFlushIntervalMs
    or
    WORKFLOW_STREAM_FLUSH_INTERVAL_MS
    .
  • Generated step, workflow and webhook bundles are ESM (the VM-executed workflow bundle stays CJS). Only matters for a host that post-processes build output.
  • Duplicate step or workflow IDs now fail the build. In 4.x, two identically named non-exported functions across workspace files collided last-write-wins. If the build fails on this, rename one of them — do not suppress the check.
  • world-postgres
    rows written before the upgrade.
    Failed runs stored by 4.x read back with
    error: undefined
    , because the payload lives in the legacy
    error
    text column rather than
    errorJson
    . There is no data migration; recent-history dashboards may show blank errors for pre-upgrade failures.
  • world-local
    stream chunks moved
    to
    streams/chunks/<streamName>/
    . Files in the old flat layout are not read back and stale files are left in place — local development state, so deleting the data directory is fine.
  • The workflow sandbox is stricter about nondeterminism.
    WeakRef
    ,
    FinalizationRegistry
    ,
    Atomics.waitAsync
    , and async
    WebAssembly
    compilation are no longer available inside workflow functions, and
    crypto.subtle.digest
    computes synchronously (same results, deterministic timing). Grep
    "use workflow"
    files for these APIs; the fix is moving that code into a step, which is a design change — flag it, do not restructure unprompted.
  • In-flight runs do not migrate. Runs created on a 4.x deployment keep executing on that deployment. Let them finish where they started; do not add code to "drain" or re-target them.
这些不是代码修改。逐项报告适用的内容,不要静默“修复”它们。
  • Tracing 默认使用
    linked
    。
    每次 workflow 和 step 调用都是独立的 trace root,并通过 span link 连接到入队位置和 run 来源,而不是每个 run 一个 trace。如果应用有按单个 trace ID 关联的仪表盘、保存的查询或告警,要么将它们迁移到
    workflow.run.id
    属性,要么设置
    WORKFLOW_TRACE_MODE=continuous
    以恢复 4.x 的形状。
  • 事件创建的前置条件防护已移除。
    WORKFLOW_PRECONDITION_GUARD
    不再存在;如果应用环境中设置了它,请将其从环境和部署配置中移除。SDK 中的任何 World 都不再拒绝针对过期快照的写入——落后的重放会从它下一次写入中学到错过的内容。
  • Turbo 模式默认开启。 run 的首次调用会将
    run_started
    放到后台,并跳过初始事件日志加载。
    WORKFLOW_TURBO=0
    可禁用它。
  • 错误会保留其类型。
    WorkflowRunFailedError.cause
    现在保留原始类身份和 cause 链。在 4.x 中由于类被扁平化而基于
    error.message
    做模式匹配的代码,可以使用
    instanceof
    ——但请标出,而不是未经提示就重写错误处理。
  • 事件 ID 是槽位号,而不是 ULID。 事件 ID 现在是它在 run 日志中的从 1 开始的位置(
    evnt_00000000000000000000000042
    ),仅在单个 run 内唯一,且不携带时间戳。如果应用代码将事件 ID 用作全局键(请将其与
    runId
    配对)或从中解码时间(请从事件上读取
    createdAt
    ),请报告。其他实体 ID 不变。
  • 强制实施每个 run 的事件上限。 World 提供上限(Local 和 Vercel Worlds 为 25,000 个事件),达到上限的 run 会以
    MAX_EVENTS_EXCEEDED
    失败。标出任何包含无界循环的 workflow;修复方法是为每个批次使用子 run,这是设计变更,而不是迁移编辑。
  • Stream 写入会立即刷出首个 chunk。 刷出窗口默认值从 10ms 变为 0。依赖该窗口来合并一小批微小 chunk 的应用可以设置
    streamFlushIntervalMs
    或
    WORKFLOW_STREAM_FLUSH_INTERVAL_MS
    。
  • 生成的 step、workflow 和 webhook bundles 是 ESM(由 VM 执行的 workflow bundle 仍是 CJS)。仅对会后处理构建输出的 host 有影响。
  • 重复的 step 或 workflow ID 现在会导致构建失败。 在 4.x 中,不同工作区文件中两个同名且未导出的函数会以“最后写入者胜出”的方式冲突。如果构建因此失败,请重命名其中一个——不要抑制该检查。
  • 升级前写入的
    world-postgres
    行。
    4.x 存储的失败 run 读回来时
    error: undefined
    ,因为负载位于旧的
    error
    文本列而不是
    errorJson
    中。没有数据迁移;最近历史仪表盘可能会对升级前的失败显示空白错误。
  • world-local
    的 stream chunks 已移动
    到
    streams/chunks/<streamName>/
    。旧扁平布局中的文件不会被读回,过期文件会留在原处——这是本地开发状态,因此删除数据目录没有问题。
  • workflow 沙箱对非确定性的限制更严格。
    WeakRef
    、
    FinalizationRegistry
    、
    Atomics.waitAsync
    和异步
    WebAssembly
    编译不再可用于 workflow 函数内部,
    crypto.subtle.digest
    现在同步计算(结果相同,时序确定)。在
    "use workflow"
    文件中搜索这些 API;修复方法是将该代码移入 step,这是设计变更——请标出,不要未经提示就重构。
  • 进行中的 run 不会迁移。 在 4.x 部署上创建的 run 会继续在该部署上执行。让它们在原处完成;不要添加“排空”或重新定向它们的代码。

Step 4 — custom
World
implementations

步骤 4 — 自定义
World
实现

Only if the app implements
World
itself. Use the
migrating-world-v4-to-v5
skill for this part
(
npx skills add https://github.com/vercel/workflow --skill migrating-world-v4-to-v5
); it carries the full World migration, including the event ID allocation work summarized below. What follows is enough to scope the job and to know when it has not been done.
Beyond the stream and step signatures above, the interface gained:
  • streams
    as a namespace (see the table in step 2).
  • analytics
    — metadata-only listings for runs, steps, events, hooks, waits, and attributes.
  • attribute support on runs, including
    experimentalSetAttributes
    .
  • capability advertisement, so the runtime can gate optimizations. Unadvertised capabilities fail closed, which means an incomplete World stays correct but slower — advertise a capability only once it is genuinely implemented. Event ID allocation is not among them: it is a requirement, not a capability. A World that rejected stale writes behind the old
    preconditionGuard
    flag can delete that code, since the flag is gone and nothing replaced it.
  • an optional per-run event ceiling returned on run reads, which the runtime enforces.
  • optional
    createRunId()
    and a
    region
    on queue options, for worlds that place run state regionally.
Four contract changes affect existing implementations. The first is required and is not visible from the type signatures:
  • Event IDs are slots, and allocating them is mandatory. An event ID is no longer a ULID the World mints freely: it is
    evnt_
    followed by the event's 1-based position in that run's log, zero-padded to 26 characters (
    slotToEventId()
    from
    @workflow/world
    formats one). Positions must be unique and dense with no holes, so the race has to be settled in the store — a unique constraint on
    (runId, eventId)
    or a conditional write — rather than by reading the maximum in process and adding one.
    events.create()
    params carry
    eventCount
    to place the write; when that position is taken the World must not reject the create, it advances to the next free position, commits there, and returns the events occupying the skipped positions on the success response. Take the position at the commit, in the same operation that appends the event, since that is what stops an event landing behind a position a reader has already passed. There is no capability to declare and no fallback: the runtime reads a position out of every ID it loads and fails the run when it cannot, so a World whose IDs are not positions type-checks, starts runs, and fails on the first replay with
    Event id is not slot-numbered
    .
    Warn the user that runs already in their store cannot be replayed by the new code — a ULID-numbered run is not readable as positions and the runtime refuses it, so those runs must be drained on 4.x first, unless the platform pins a run to its creating deployment (Vercel does, which resolves it). Point them at
    @workflow/world-testing
    's
    numbers events by position
    , which catches a wrong ID scheme in the conformance suite instead of at replay time, and at the "Upgrading a World to v5" guide for the full rules.
  • Suspension and dispatch. The asymmetric
    { timeoutSeconds }
    return contract for waits is gone. Waits are ordinary queue continuations carrying
    delaySeconds
    , and wait plus step dispatch is unified into one parallel batch per suspension. A World that special-cased the old wait return needs rewriting against the current interface.
  • Step queue topics are retired. The
    'step'
    queue kind no longer exists: queued steps travel on the workflow topic (carrying
    stepId
    /
    stepName
    in the payload) and execute in the combined flow handler. A World that provisioned or routed separate
    __wkf_step_*
    topics can drop them.
  • World resolution happens at build time. Worlds are statically injected into host bundles rather than selected dynamically at runtime, and first-party World packages expose a
    createWorld()
    factory. A custom or community World must be resolvable by the build; verify the app still boots against it rather than assuming a runtime lookup.
Optional methods may be omitted; the runtime falls back. Point the user at the
migrating-world-v4-to-v5
skill and the "Upgrading a World to v5" guide, which carry the full interface delta and the contract changes above, rather than inventing method bodies.
仅当应用自己实现
World
时才需要。这部分请使用
migrating-world-v4-to-v5
skill
(
npx skills add https://github.com/vercel/workflow --skill migrating-world-v4-to-v5
);它包含完整的 World 迁移,包括下面总结的事件 ID 分配工作。以下内容足以界定工作范围,并判断迁移是否尚未完成。
除了上面的 stream 和 step 签名外,接口还新增了:
  • streams
    作为命名空间(见步骤 2 中的表格)。
  • analytics
    —— 仅返回元数据的 runs、steps、events、hooks、waits 和 attributes 列表。
  • run 上的 attribute 支持,包括
    experimentalSetAttributes
    。
  • 能力通告,使运行时可以对优化进行门控。未通告的能力会以“失败关闭”方式处理,这意味着不完整的 World 仍然正确但更慢——只有在真正实现某个能力后才通告它。事件 ID 分配不在其中:它是一个要求,而不是能力。过去在旧的
    preconditionGuard
    标志后拒绝过期写入的 World 可以删除该代码,因为该标志已不存在,也没有任何东西取代它。
  • 在 run 读取时返回的可选每个 run 事件上限,由运行时强制实施。
  • 可选的
    createRunId()
    和 queue 选项上的
    region
    ,供按区域放置 run 状态的 worlds 使用。
四个契约变更会影响现有实现。第一个是必须的,并且从类型签名中看不出来:
  • 事件 ID 是槽位,并且必须分配它们。 事件 ID 不再是 World 随意生成的 ULID:它是
    evnt_
    后跟该事件在该 run 日志中的从 1 开始的位置,零填充到 26 个字符(
    @workflow/world
    中的
    slotToEventId()
    可以格式化一个)。位置必须唯一且密集、不能有空洞,因此竞争必须在存储中解决——在
    (runId, eventId)
    上加唯一约束,或使用条件写入——而不是在进程中读取最大值再加一。
    events.create()
    参数携带
    eventCount
    来放置写入;当该位置已被占用时,World 不能拒绝创建,而要推进到下一个空闲位置,在那里提交,并在成功响应中返回占据被跳过位置的 events。在提交时获取位置,与追加事件的同一个操作中完成,因为这样才能阻止事件落在读取者已经越过的位置之后。没有可声明的能力,也没有回退:运行时会从它加载的每个 ID 中读取位置,无法读取时会让 run 失败,因此 ID 不是位置的 World 能通过类型检查、能启动 run,但会在第一次重放时以
    Event id is not slot-numbered
    失败。
    警告用户,其存储中已有的 run 无法被新代码重放——带 ULID 编号的 run 不能按位置读取,运行时会拒绝它,因此这些 run 必须先在 4.x 上排空,除非平台将 run 固定到创建它的部署(Vercel 会这样做,这解决了问题)。请指向
    @workflow/world-testing
    的
    numbers events by position
    ,它会在一致性测试套件中捕获错误的 ID 方案,而不是在重放时,并指向 “Upgrading a World to v5” 指南以了解完整规则。
  • 挂起与分发。 等待的异步
    { timeoutSeconds }
    返回契约已移除。等待变成了携带
    delaySeconds
    的普通队列延续,等待和 step 分发被统一为每次挂起一个并行批次。特殊处理旧等待返回的 World 需要针对当前接口重写。
  • Step 队列主题已退役。
    'step'
    队列类型不再存在:排队的 step 在 workflow 主题上传输(在负载中携带
    stepId
    /
    stepName
    ),并在合并的 flow handler 中执行。曾经预置或路由独立
    __wkf_step_*
    主题的 World 可以删除它们。
  • World 解析发生在构建时。 Worlds 被静态注入 host bundle,而不是在运行时动态选择;第一方 World 包暴露
    createWorld()
    工厂。自定义或社区 World 必须能被构建解析;请验证应用仍然可以启动并依赖它,而不是假设运行时查找。
可选方法可以省略;运行时会回退。请让用户参考
migrating-world-v4-to-v5
skill 和 “Upgrading a World to v5” 指南,它们包含完整的接口差异和上述契约变更,而不是自行发明方法体。

Required output shape

要求的输出结构

Return the migration in this structure:
md
undefined
按以下结构返回迁移结果:
md
undefined

Summary

Summary

Dependency Changes

Dependency Changes

Code Changes

Code Changes

Behavior Changes To Review

Behavior Changes To Review

Verification

Verification

Open Questions

Open Questions


- `## Code Changes` lists one entry per rule applied, with the file paths touched. Rules that did not apply are listed once as not applicable — do not omit them silently.
- `## Behavior Changes To Review` carries the step 3 items that apply to this app, each with the concrete follow-up (which dashboard, which env var).
- `## Open Questions` carries anything that could not be decided from the code, especially a custom World that needs interface work.

- `## Code Changes` 列出应用到的每条规则的一个条目,并包含涉及的文件路径。不适用的规则应一次性列为“不适用”——不要静默省略。
- `## Behavior Changes To Review` 包含适用于该应用的步骤 3 中的项目,每项都带有具体的后续行动(哪个仪表盘、哪个环境变量)。
- `## Open Questions` 包含无法从代码确定的内容,尤其是需要接口工作的自定义 World。

Verification

验证

Run, in order, and report actual output:
  1. Install and build. The build must regenerate workflow/step bundles without errors.
  2. Typecheck. The async
    getWorld()
    change and the
    steps.get
    signature surface here.
  3. The app's test suite.
  4. Start the app and execute one real run end to end, confirming the first step runs and the run reaches a terminal state.
Fail the migration if any of these are true:
  • workflow
    and
    @workflow/*
    versions span both 4.x and 5.x
  • getWorld()
    /
    createWorld()
    /
    getWorldHandlers()
    is called without
    await
  • runStep
    or
    stepEntrypoint
    is still imported
  • createLocalWorld
    or
    createVercelWorld
    is still imported
  • workflow/internal/private
    or
    @workflow/core/private
    is still imported
  • a
    world.streams.*
    call kept the v4 argument order (name before runId)
  • streams.get
    was called without a
    runId
  • world.steps.get
    was called with
    undefined
    as its first argument
  • NestLocalBuilder
    is imported from
    @workflow/nest
    instead of
    workflow/nest/builder
  • a compiler call still passes
    mode: 'client'
  • metadata
    is read off a
    getHookByToken()
    or
    resumeHook()
    result without
    await
  • a behavior change from step 3 was silently "fixed" instead of reported
  • workflow or step bodies were restructured beyond the rules above
  • generated output under
    .well-known/workflow/v1/
    was hand-edited
  • the build, typecheck, or test results were not actually run and reported
按顺序运行并报告实际输出:
  1. 安装并构建。构建必须无错误地重新生成 workflow/step bundles。
  2. 类型检查。异步
    getWorld()
    变更和
    steps.get
    签名会在这里暴露。
  3. 应用的测试套件。
  4. 启动应用并端到端执行一次真实 run,确认第一个 step 运行且 run 达到终态。
如果以下任一情况为真,则迁移失败:
  • workflow
    和
    @workflow/*
    版本同时包含 4.x 和 5.x
  • getWorld()
    /
    createWorld()
    /
    getWorldHandlers()
    被调用时没有
    await
  • 仍导入了
    runStep
    或
    stepEntrypoint
  • 仍导入了
    createLocalWorld
    或
    createVercelWorld
  • 仍导入了
    workflow/internal/private
    或
    @workflow/core/private
  • world.streams.*
    调用保留了 v4 参数顺序(name 在 runId 之前)
  • streams.get
    被调用时没有传
    runId
  • world.steps.get
    被调用时第一个参数是
    undefined
  • NestLocalBuilder
    是从
    @workflow/nest
    而不是
    workflow/nest/builder
    导入的
  • 编译器调用仍传入了
    mode: 'client'
  • 在
    getHookByToken()
    或
    resumeHook()
    的结果上读取
    metadata
    时没有
    await
  • 步骤 3 中的行为变更被静默“修复”而不是报告
  • workflow 或 step 函数体被重构,超出了上述规则
  • .well-known/workflow/v1/
    下的生成输出被手动编辑
  • 构建、类型检查或测试结果没有实际运行和报告

Reference

参考