workflow 5.0.0-beta.9 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -23
- package/dist/api-workflow.d.ts +3 -1
- package/dist/api-workflow.d.ts.map +1 -1
- package/dist/api-workflow.js +2 -1
- package/dist/api.d.ts +5 -4
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +6 -7
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/internal/builtins.d.ts +4 -4
- package/dist/internal/builtins.js +6 -6
- package/dist/internal/errors.d.ts +1 -1
- package/dist/internal/errors.d.ts.map +1 -1
- package/dist/internal/errors.js +2 -2
- package/dist/nest-builder.d.ts +2 -0
- package/dist/nest-builder.d.ts.map +1 -0
- package/dist/nest-builder.js +2 -0
- package/dist/nest-vercel-builder.d.ts +2 -0
- package/dist/nest-vercel-builder.d.ts.map +1 -0
- package/dist/nest-vercel-builder.js +2 -0
- package/dist/runtime.d.ts +2 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +4 -1
- package/docs/advanced/dynamic-workflows.mdx +224 -0
- package/docs/ai/chat-session-modeling.mdx +176 -422
- package/docs/ai/defining-tools.mdx +6 -7
- package/docs/ai/human-in-the-loop.mdx +11 -11
- package/docs/ai/index.mdx +67 -72
- package/docs/ai/message-queueing.mdx +71 -110
- package/docs/ai/meta.json +1 -0
- package/docs/ai/resumable-streams.mdx +40 -28
- package/docs/ai/sleep-and-delays.mdx +10 -10
- package/docs/ai/streaming-updates-from-tools.mdx +6 -6
- package/docs/api-reference/index.mdx +25 -1
- package/docs/api-reference/meta.json +8 -0
- package/docs/api-reference/vitest/index.mdx +68 -15
- package/docs/api-reference/workflow/create-hook.mdx +166 -10
- package/docs/api-reference/workflow/create-webhook.mdx +16 -15
- package/docs/api-reference/workflow/define-hook.mdx +37 -33
- package/docs/api-reference/workflow/fatal-error.mdx +30 -8
- package/docs/api-reference/workflow/fetch.mdx +14 -10
- package/docs/api-reference/workflow/get-step-metadata.mdx +2 -2
- package/docs/api-reference/workflow/get-workflow-metadata.mdx +3 -3
- package/docs/api-reference/workflow/get-writable.mdx +7 -7
- package/docs/api-reference/workflow/index.mdx +3 -3
- package/docs/api-reference/workflow/retryable-error.mdx +1 -1
- package/docs/api-reference/workflow/set-attributes.mdx +63 -0
- package/docs/api-reference/workflow/sleep.mdx +4 -4
- package/docs/api-reference/workflow-ai/durable-agent.mdx +63 -101
- package/docs/api-reference/workflow-ai/index.mdx +5 -5
- package/docs/api-reference/workflow-ai/workflow-chat-transport.mdx +67 -24
- package/docs/api-reference/workflow-api/get-hook-by-token.mdx +28 -12
- package/docs/api-reference/workflow-api/get-run.mdx +43 -8
- package/docs/api-reference/workflow-api/index.mdx +8 -9
- package/docs/api-reference/workflow-api/register-lifecycle-hooks.mdx +87 -0
- package/docs/api-reference/workflow-api/resume-hook.mdx +73 -12
- package/docs/api-reference/workflow-api/resume-webhook.mdx +11 -9
- package/docs/api-reference/workflow-api/start.mdx +107 -12
- package/docs/api-reference/workflow-astro/index.mdx +18 -0
- package/docs/api-reference/workflow-astro/meta.json +4 -0
- package/docs/api-reference/workflow-astro/workflow.mdx +45 -0
- package/docs/api-reference/workflow-errors/entity-conflict-error.mdx +4 -4
- package/docs/api-reference/workflow-errors/hook-conflict-error.mdx +60 -0
- package/docs/api-reference/workflow-errors/hook-force-claimed-error.mdx +70 -0
- package/docs/api-reference/workflow-errors/hook-not-found-error.mdx +8 -8
- package/docs/api-reference/workflow-errors/index.mdx +91 -0
- package/docs/api-reference/workflow-errors/meta.json +7 -0
- package/docs/api-reference/workflow-errors/precondition-failed-error.mdx +68 -0
- package/docs/api-reference/workflow-errors/run-expired-error.mdx +2 -2
- package/docs/api-reference/workflow-errors/run-not-supported-error.mdx +58 -0
- package/docs/api-reference/workflow-errors/step-not-registered-error.mdx +5 -5
- package/docs/api-reference/workflow-errors/throttle-error.mdx +2 -2
- package/docs/api-reference/workflow-errors/too-early-error.mdx +2 -2
- package/docs/api-reference/workflow-errors/workflow-error.mdx +52 -0
- package/docs/api-reference/workflow-errors/workflow-not-registered-error.mdx +5 -6
- package/docs/api-reference/workflow-errors/workflow-run-cancelled-error.mdx +13 -6
- package/docs/api-reference/workflow-errors/workflow-run-failed-error.mdx +12 -5
- package/docs/api-reference/workflow-errors/workflow-run-not-completed-error.mdx +58 -0
- package/docs/api-reference/workflow-errors/workflow-run-not-found-error.mdx +4 -4
- package/docs/api-reference/workflow-errors/workflow-runtime-error.mdx +58 -0
- package/docs/api-reference/workflow-errors/workflow-world-error.mdx +8 -8
- package/docs/api-reference/workflow-globals.mdx +15 -11
- package/docs/api-reference/workflow-nest/configure-workflow-controller.mdx +37 -0
- package/docs/api-reference/workflow-nest/index.mdx +31 -0
- package/docs/api-reference/workflow-nest/meta.json +9 -0
- package/docs/api-reference/workflow-nest/nest-local-builder.mdx +64 -0
- package/docs/api-reference/workflow-nest/workflow-controller.mdx +46 -0
- package/docs/api-reference/workflow-nest/workflow-module.mdx +134 -0
- package/docs/api-reference/workflow-next/with-workflow.mdx +39 -17
- package/docs/api-reference/workflow-nitro/index.mdx +60 -0
- package/docs/api-reference/workflow-nuxt/index.mdx +48 -0
- package/docs/api-reference/workflow-observability/hydrate-data.mdx +35 -0
- package/docs/api-reference/workflow-observability/hydrate-resource-io.mdx +62 -0
- package/docs/api-reference/workflow-observability/index.mdx +62 -0
- package/docs/api-reference/workflow-observability/meta.json +11 -0
- package/docs/api-reference/workflow-observability/observability-revivers.mdx +50 -0
- package/docs/api-reference/workflow-observability/parse-class-name.mdx +41 -0
- package/docs/api-reference/workflow-observability/parse-step-name.mdx +40 -0
- package/docs/api-reference/workflow-observability/parse-workflow-name.mdx +55 -0
- package/docs/api-reference/workflow-runtime/create-world.mdx +39 -0
- package/docs/api-reference/workflow-runtime/get-world-handlers.mdx +44 -0
- package/docs/api-reference/{workflow-api → workflow-runtime}/get-world.mdx +11 -14
- package/docs/api-reference/workflow-runtime/health-check.mdx +51 -0
- package/docs/api-reference/workflow-runtime/index.mdx +41 -0
- package/docs/api-reference/workflow-runtime/meta.json +12 -0
- package/docs/api-reference/workflow-runtime/set-world.mdx +51 -0
- package/docs/api-reference/workflow-runtime/workflow-entrypoint.mdx +43 -0
- package/docs/api-reference/workflow-runtime/world/analytics.mdx +315 -0
- package/docs/api-reference/workflow-runtime/world/index.mdx +60 -0
- package/docs/api-reference/workflow-runtime/world/meta.json +4 -0
- package/docs/api-reference/workflow-runtime/world/queue.mdx +88 -0
- package/docs/api-reference/{workflow-api → workflow-runtime}/world/storage.mdx +104 -35
- package/docs/api-reference/{workflow-api → workflow-runtime}/world/streams.mdx +8 -8
- package/docs/api-reference/workflow-serde/index.mdx +1 -2
- package/docs/api-reference/workflow-serde/workflow-deserialize.mdx +3 -4
- package/docs/api-reference/workflow-serde/workflow-serialize.mdx +8 -8
- package/docs/api-reference/workflow-sveltekit/index.mdx +18 -0
- package/docs/api-reference/workflow-sveltekit/meta.json +4 -0
- package/docs/api-reference/workflow-sveltekit/workflow-plugin.mdx +42 -0
- package/docs/api-reference/workflow-vite/index.mdx +18 -0
- package/docs/api-reference/workflow-vite/meta.json +4 -0
- package/docs/api-reference/workflow-vite/workflow.mdx +48 -0
- package/docs/changelog/attributes-mvp.mdx +53 -41
- package/docs/changelog/batched-event-writes.mdx +79 -0
- package/docs/changelog/eager-processing.mdx +110 -436
- package/docs/changelog/index.mdx +4 -2
- package/docs/changelog/lazy-event-creation.md +127 -0
- package/docs/changelog/lazy-hook-resume.mdx +78 -0
- package/docs/changelog/meta.json +11 -1
- package/docs/changelog/resilient-resume.mdx +32 -0
- package/docs/changelog/resilient-start.mdx +33 -285
- package/docs/changelog/step-message-ownership.mdx +360 -0
- package/docs/changelog/turbo-mode.md +87 -0
- package/docs/comparisons/index.mdx +66 -0
- package/docs/comparisons/meta.json +11 -0
- package/docs/comparisons/workflow-sdk-vs-aws-agentcore.mdx +55 -0
- package/docs/comparisons/workflow-sdk-vs-aws-step-functions.mdx +111 -0
- package/docs/comparisons/workflow-sdk-vs-cloudflare-workflows.mdx +71 -0
- package/docs/comparisons/workflow-sdk-vs-inngest.mdx +102 -0
- package/docs/comparisons/workflow-sdk-vs-temporal.mdx +123 -0
- package/docs/comparisons/workflow-sdk-vs-trigger-dev.mdx +104 -0
- package/docs/configuration/build-and-diagnostics.mdx +79 -0
- package/docs/configuration/cli-and-web-ui.mdx +241 -0
- package/docs/configuration/framework-options.mdx +165 -0
- package/docs/configuration/index.mdx +32 -0
- package/docs/configuration/meta.json +12 -0
- package/docs/configuration/runtime-tuning.mdx +399 -0
- package/docs/configuration/worlds.mdx +315 -0
- package/docs/cookbook/advanced/child-workflows.mdx +33 -25
- package/docs/cookbook/advanced/publishing-libraries.mdx +65 -56
- package/docs/cookbook/advanced/serializable-steps.mdx +48 -68
- package/docs/cookbook/advanced/upgrading-workflows.mdx +35 -31
- package/docs/cookbook/agent-patterns/agent-cancellation.mdx +78 -60
- package/docs/cookbook/agent-patterns/durable-agent.mdx +23 -135
- package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +180 -195
- package/docs/cookbook/common-patterns/batching.mdx +20 -14
- package/docs/cookbook/common-patterns/idempotency.mdx +41 -53
- package/docs/cookbook/common-patterns/rate-limiting.mdx +8 -4
- package/docs/cookbook/common-patterns/saga.mdx +23 -19
- package/docs/cookbook/common-patterns/scheduling.mdx +30 -22
- package/docs/cookbook/common-patterns/sequential-and-parallel.mdx +29 -25
- package/docs/cookbook/common-patterns/timeouts.mdx +26 -21
- package/docs/cookbook/common-patterns/webhooks.mdx +10 -6
- package/docs/cookbook/common-patterns/workflow-composition.mdx +27 -17
- package/docs/cookbook/index.mdx +22 -22
- package/docs/cookbook/integrations/ai-sdk.mdx +63 -48
- package/docs/cookbook/integrations/chat-sdk.mdx +46 -33
- package/docs/cookbook/integrations/sandbox.mdx +58 -45
- package/docs/deploying.mdx +106 -0
- package/docs/errors/abort-signal-timeout-in-workflow.mdx +16 -12
- package/docs/errors/corrupted-event-log.mdx +39 -18
- package/docs/errors/deployment-mismatch.mdx +71 -0
- package/docs/errors/fetch-in-workflow.mdx +15 -14
- package/docs/errors/hook-conflict.mdx +38 -11
- package/docs/errors/hook-force-claimed.mdx +96 -0
- package/docs/errors/index.mdx +24 -37
- package/docs/errors/node-js-module-in-workflow.mdx +9 -5
- package/docs/errors/replay-divergence.mdx +27 -0
- package/docs/errors/run-expired.mdx +85 -0
- package/docs/errors/runtime-decryption-failed.mdx +77 -0
- package/docs/errors/serialization-failed.mdx +44 -12
- package/docs/errors/start-invalid-workflow-function.mdx +9 -5
- package/docs/errors/step-executed-multiple-times.mdx +23 -0
- package/docs/errors/step-not-registered.mdx +6 -6
- package/docs/errors/timeout-in-workflow.mdx +12 -8
- package/docs/errors/webhook-invalid-respond-with-value.mdx +18 -18
- package/docs/errors/webhook-response-not-sent.mdx +20 -16
- package/docs/errors/workflow-not-registered.mdx +5 -5
- package/docs/foundations/cancellation.mdx +31 -32
- package/docs/foundations/errors-and-retries.mdx +54 -11
- package/docs/foundations/hooks.mdx +98 -35
- package/docs/foundations/idempotency.mdx +267 -12
- package/docs/foundations/index.mdx +1 -26
- package/docs/foundations/serialization.mdx +22 -22
- package/docs/foundations/starting-workflows.mdx +104 -30
- package/docs/foundations/streaming.mdx +108 -60
- package/docs/foundations/versioning.mdx +4 -4
- package/docs/foundations/workflows-and-steps.mdx +9 -9
- package/docs/getting-started/astro.mdx +22 -18
- package/docs/getting-started/express.mdx +15 -11
- package/docs/getting-started/fastify.mdx +15 -11
- package/docs/getting-started/hono.mdx +15 -11
- package/docs/getting-started/index.mdx +10 -3
- package/docs/getting-started/meta.json +3 -1
- package/docs/getting-started/nestjs.mdx +264 -21
- package/docs/getting-started/next.mdx +18 -14
- package/docs/getting-started/nitro.mdx +22 -18
- package/docs/getting-started/nuxt.mdx +15 -11
- package/docs/getting-started/python.mdx +190 -41
- package/docs/getting-started/react-router/index.mdx +33 -0
- package/docs/getting-started/react-router/meta.json +5 -0
- package/docs/getting-started/react-router/v7.mdx +237 -0
- package/docs/getting-started/react-router/v8.mdx +232 -0
- package/docs/getting-started/sveltekit.mdx +20 -16
- package/docs/getting-started/tanstack-start.mdx +17 -13
- package/docs/getting-started/vite.mdx +15 -11
- package/docs/how-it-works/cancellation.mdx +63 -63
- package/docs/how-it-works/code-transform.mdx +82 -66
- package/docs/how-it-works/encryption.mdx +30 -26
- package/docs/how-it-works/event-sourcing.mdx +132 -35
- package/docs/how-it-works/framework-integrations.mdx +96 -337
- package/docs/how-it-works/understanding-directives.mdx +22 -22
- package/docs/internal/index.mdx +6 -4
- package/docs/internal/meta.json +6 -1
- package/docs/internal/nitro-native-build.mdx +38 -0
- package/docs/internal/nitro-web-ui.mdx +24 -0
- package/docs/internal/serializable-abort-controller.mdx +7 -7
- package/docs/meta.json +4 -2
- package/docs/observability/attributes.mdx +91 -21
- package/docs/observability/index.mdx +29 -15
- package/docs/observability/lifecycle-hooks.mdx +95 -0
- package/docs/observability/meta.json +1 -1
- package/docs/observability/retention.mdx +95 -0
- package/docs/observability/tracing.mdx +124 -0
- package/docs/testing/index.mdx +120 -38
- package/docs/testing/server-based.mdx +10 -10
- package/docs/whats-new.mdx +190 -0
- package/docs/worlds/building-a-world.mdx +538 -0
- package/docs/worlds/local.mdx +129 -0
- package/docs/worlds/meta.json +10 -0
- package/docs/worlds/postgres.mdx +424 -0
- package/docs/worlds/upgrading-to-v5.mdx +162 -0
- package/docs/worlds/vercel.mdx +345 -0
- package/package.json +17 -14
- package/docs/api-reference/workflow/experimental-set-attributes.mdx +0 -63
- package/docs/api-reference/workflow-api/world/index.mdx +0 -58
- package/docs/api-reference/workflow-api/world/meta.json +0 -4
- package/docs/api-reference/workflow-api/world/observability.mdx +0 -164
- package/docs/api-reference/workflow-api/world/queue.mdx +0 -86
- package/docs/deploying/building-a-world.mdx +0 -251
- package/docs/deploying/index.mdx +0 -95
- package/docs/deploying/meta.json +0 -4
- package/docs/deploying/world/local-world.mdx +0 -84
- package/docs/deploying/world/meta.json +0 -4
- package/docs/deploying/world/postgres-world.mdx +0 -224
- package/docs/deploying/world/vercel-world.mdx +0 -181
- package/docs/migration-guides/index.mdx +0 -34
- package/docs/migration-guides/meta.json +0 -9
- package/docs/migration-guides/migrating-from-aws-step-functions.mdx +0 -358
- package/docs/migration-guides/migrating-from-inngest.mdx +0 -304
- package/docs/migration-guides/migrating-from-temporal.mdx +0 -313
- package/docs/migration-guides/migrating-from-trigger-dev.mdx +0 -328
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Building a World
|
|
3
|
+
description: Implement the World interface to run workflows on any custom infrastructure.
|
|
4
|
+
type: guide
|
|
5
|
+
summary: Build a custom World adapter to run workflows on your own infrastructure.
|
|
6
|
+
prerequisites:
|
|
7
|
+
- /docs/deploying
|
|
8
|
+
- /docs/foundations/workflows-and-steps
|
|
9
|
+
related:
|
|
10
|
+
- /worlds/upgrading-to-v5
|
|
11
|
+
- /worlds/local
|
|
12
|
+
- /worlds/postgres
|
|
13
|
+
- /worlds/vercel
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
A **World** is the abstraction that allows workflows to run on any infrastructure. It handles workflow storage, step execution queuing, and data streaming. This guide explains the World interface and how to implement your own.
|
|
17
|
+
|
|
18
|
+
<Callout>
|
|
19
|
+
Before building a custom World, check the [Worlds ecosystem](/worlds) page. There may already be a community implementation for your infrastructure.
|
|
20
|
+
</Callout>
|
|
21
|
+
|
|
22
|
+
<Callout type="info">
|
|
23
|
+
**Reference implementation:** The [Postgres World source code](https://github.com/vercel/workflow/tree/main/packages/world-postgres) is a complete example of how to implement the World interface with a database backend and graphile-worker for queuing. It is not optimized for scale, speed, or security and should be used as a reference to fill in those gaps in a custom World. Please see the [Postgres World docs](/worlds/postgres) for what a self-hosted World has to gate itself.
|
|
24
|
+
</Callout>
|
|
25
|
+
|
|
26
|
+
<Callout type="info">
|
|
27
|
+
If you have a World on the 4.x spec, see [Upgrading a World to v5](/worlds/upgrading-to-v5) for the interface and contract changes.
|
|
28
|
+
</Callout>
|
|
29
|
+
|
|
30
|
+
## What is a World?
|
|
31
|
+
|
|
32
|
+
A World connects workflows to the infrastructure that powers them. The World interface abstracts three core responsibilities:
|
|
33
|
+
|
|
34
|
+
1. **Storage**: Persists workflow runs, steps, hooks, and the event log
|
|
35
|
+
2. **Queue**: Enqueues and processes workflow and step invocations
|
|
36
|
+
3. **Streamer**: Manages real-time data streams between workflows and clients
|
|
37
|
+
|
|
38
|
+
{/* @skip-typecheck - interface definition, not runnable code */}
|
|
39
|
+
```typescript
|
|
40
|
+
interface WorldCapabilities {
|
|
41
|
+
hookRetention?: {
|
|
42
|
+
active: boolean;
|
|
43
|
+
};
|
|
44
|
+
maxConcurrency?: boolean;
|
|
45
|
+
hookForceClaim?: boolean;
|
|
46
|
+
dynamicWorkflowCode?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface World extends Storage, Queue, Streamer {
|
|
50
|
+
specVersion: number;
|
|
51
|
+
capabilities?: WorldCapabilities;
|
|
52
|
+
analytics?: Analytics;
|
|
53
|
+
start?(): Promise<void>;
|
|
54
|
+
close?(): Promise<void>;
|
|
55
|
+
getEncryptionKeyForRun?(run: WorkflowRun): Promise<Uint8Array | undefined>;
|
|
56
|
+
getEncryptionKeyForRun?(runId: string, context?: Record<string, unknown>): Promise<Uint8Array | undefined>;
|
|
57
|
+
createRunId?(options?: Readonly<Record<string, unknown>>): string;
|
|
58
|
+
describeRun?(run: Readonly<Record<string, unknown>>): Record<string, string | null> | null | Promise<Record<string, string | null> | null>;
|
|
59
|
+
processExitTriggersQueueRedelivery?: boolean;
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`specVersion` is required. See [Declaring the spec version](#declaring-the-spec-version).
|
|
64
|
+
|
|
65
|
+
The optional `capabilities` object advertises additional behavior, and every capability **fails closed**. A missing member means "unsupported," and the runtime keeps its conservative behavior. Set `hookRetention.active` to `true` only when the World implements hook token retention. Set `maxConcurrency` only when the World's queue supports `maxConcurrency`-limited consumption, which `WORKFLOW_SEQUENTIAL_REPLAYS=1` uses. Set `hookForceClaim` only when the World implements [hook token takeover](/worlds/upgrading-to-v5#new-optional-surface). Without it, `createHook({ experimental_force: true })` fails the workflow when it registers the hook. Set `dynamicWorkflowCode` only when the World persists `dynamicWorkflowCode` from `run_created` (and from a `run_started` that creates the run), echoes it on the created run, and returns it from `runs.get` with `resolveData: 'all'`. Without it, `start()` refuses [dynamic workflows](/docs/advanced/dynamic-workflows). The code is sent inline unless the World also implements `uploadDynamicWorkflowCode` for large definitions.
|
|
66
|
+
|
|
67
|
+
[Slot-numbered event IDs](#event-id-allocation) are a contract requirement rather than a capability, so there is no flag or fallback path.
|
|
68
|
+
|
|
69
|
+
The remaining optional members:
|
|
70
|
+
|
|
71
|
+
- `analytics` provides metadata-only listings of runs, steps, events, hooks, waits, and attributes for observability surfaces. `workflow inspect` and the local web UI read from it, including for attribute search. See [Analytics interface](#analytics-interface-optional).
|
|
72
|
+
- `start()` initializes background tasks (for example, queue polling); `close()` releases resources like connection pools and listeners.
|
|
73
|
+
- `getEncryptionKeyForRun()` returns the AES-256 key used to encrypt data for a run; if it is not implemented, encryption is disabled.
|
|
74
|
+
- `createRunId()` mints the ID for a new run. Implementations may embed World-specific metadata as long as the result remains a valid ULID. This is how [multi-region placement](/worlds/vercel#multi-region) works. `@workflow/world-vercel` reads `options.region` from the `start()` options and embeds a region tag. When omitted, the runtime generates a standard monotonic ULID.
|
|
75
|
+
- `describeRun()` returns world-specific display fields for a run (for example, a region decoded from its ID) that tooling like `workflow inspect` renders as extra columns. It must be cheap, tolerate missing fields, and never throw.
|
|
76
|
+
- `processExitTriggersQueueRedelivery` tells the runtime how to handle an exhausted replay budget. When `true`, it exits and relies on queue redelivery. When `false` or absent, it attempts to write `run_failed` and returns.
|
|
77
|
+
|
|
78
|
+
### Declaring the spec version
|
|
79
|
+
|
|
80
|
+
`specVersion` is the protocol version your World implements, and the version stamped on every run it creates. Export `SPEC_VERSION_CURRENT` from `@workflow/world` rather than writing a number:
|
|
81
|
+
|
|
82
|
+
{/* @skip-typecheck - partial World, the other members are elided */}
|
|
83
|
+
```typescript
|
|
84
|
+
import { SPEC_VERSION_CURRENT } from '@workflow/world';
|
|
85
|
+
|
|
86
|
+
export function createWorld(): World {
|
|
87
|
+
return {
|
|
88
|
+
specVersion: SPEC_VERSION_CURRENT,
|
|
89
|
+
// ...
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The runtime checks this before it creates or replays anything and throws if the version is outside the range it supports, naming both the range and what your World declared. A version below the range means your World allocates event IDs the runtime cannot read positions out of; above it means your World speaks a protocol this runtime has not learned.
|
|
95
|
+
|
|
96
|
+
Using the constant keeps that check passing across upgrades. It moves with the `@workflow/world` version your package resolves, so a spec bump raises your declaration and the runtime's requirement together. A hard-coded number leaves your World a version behind the next bump, and the runtime rejects it. Keep `@workflow/world` in the same release channel as the `workflow` version your users install.
|
|
97
|
+
|
|
98
|
+
## The event log model
|
|
99
|
+
|
|
100
|
+
Workflow storage uses an **append-only event log**. All state changes happen through events. Instead of modifying runs, steps, or hooks directly, you create events that update the materialized state.
|
|
101
|
+
|
|
102
|
+
Events fall into three categories: run lifecycle events, step lifecycle events, and hook lifecycle events. See the [Event Sourcing](/docs/how-it-works/event-sourcing) documentation for a complete list of event types and their semantics.
|
|
103
|
+
|
|
104
|
+
## Storage interface
|
|
105
|
+
|
|
106
|
+
The Storage interface provides read access to materialized entities and write access through events:
|
|
107
|
+
|
|
108
|
+
{/* @skip-typecheck - interface definition, not runnable code */}
|
|
109
|
+
```typescript
|
|
110
|
+
interface Storage {
|
|
111
|
+
runs: {
|
|
112
|
+
get(id: string, params?: GetWorkflowRunParams): Promise<WorkflowRun>;
|
|
113
|
+
list(params?: ListWorkflowRunsParams): Promise<PaginatedResponse<WorkflowRun>>;
|
|
114
|
+
// Optional: backs setAttributes(). Omitting it means run attributes are
|
|
115
|
+
// unsupported on this World. The SDK helper no-ops with a warning.
|
|
116
|
+
experimentalSetAttributes?(runId: string, changes: AttributeChange[], options?: { allowReservedAttributes?: boolean }): Promise<ExperimentalSetAttributesResult>;
|
|
117
|
+
|
|
118
|
+
// Optional: long poll for a terminal status (see below)
|
|
119
|
+
waitForTerminalStatus?(id: string, params?: WaitForTerminalRunStatusParams): Promise<WorkflowRun>;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
steps: {
|
|
123
|
+
get(runId: string, stepId: string, params?: GetStepParams): Promise<Step>;
|
|
124
|
+
list(params: ListWorkflowRunStepsParams): Promise<PaginatedResponse<Step>>;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
events: {
|
|
128
|
+
// Create a new workflow run (runId may be client-provided or null for server generation)
|
|
129
|
+
create(runId: string | null, data: RunCreatedEventRequest, params?: CreateEventParams): Promise<EventResult>;
|
|
130
|
+
|
|
131
|
+
// Create an event for an existing run
|
|
132
|
+
create(runId: string, data: CreateEventRequest, params?: CreateEventParams): Promise<EventResult>;
|
|
133
|
+
|
|
134
|
+
list(params: ListEventsParams): Promise<PaginatedResponse<Event>>;
|
|
135
|
+
listByCorrelationId(params: ListEventsByCorrelationIdParams): Promise<PaginatedResponse<Event>>;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
hooks: {
|
|
139
|
+
get(hookId: string, params?: GetHookParams): Promise<Hook>;
|
|
140
|
+
getByToken(token: string, params?: GetHookParams): Promise<Hook>;
|
|
141
|
+
list(params: ListHooksParams): Promise<PaginatedResponse<Hook>>;
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Key implementation details
|
|
147
|
+
|
|
148
|
+
**Event creation:** When `events.create()` is called, your implementation must:
|
|
149
|
+
|
|
150
|
+
1. Persist the event to the event log
|
|
151
|
+
2. Atomically update the affected entity (run, step, or hook)
|
|
152
|
+
3. Return both the created event and the updated entity
|
|
153
|
+
|
|
154
|
+
**Run creation:** For `run_created` events, the `runId` parameter may be a client-provided string or `null`. When `null`, your World generates and returns a new `runId`.
|
|
155
|
+
|
|
156
|
+
**Event data resolution:** `events.list()` and `events.create()` accept `resolveData: 'skip-step-inputs'`, and the runtime replays with it. The World may leave `input` out of `step_created` and `step_started` events, and returns everything else as for `'all'`. Treat any value other than `'none'` as `'all'`, because a World that tests `resolveData === 'all'` strips step results and breaks every replay. Map the value with `entityResolveData()` from `@workflow/world` before passing it to an entity read.
|
|
157
|
+
|
|
158
|
+
**Run listing:** `runs.list({ status })` accepts a single status or an array. With an array, return runs whose status matches any listed value, and return no runs for an empty array (the same semantics as SQL `IN ()`). Treat an omitted `status` as no filter.
|
|
159
|
+
|
|
160
|
+
**Hook tokens:** Hook tokens must be unique. If a `hook_created` event conflicts with an active hook or a token still reserved after its run ended, return a `hook_conflict` event and include the owner's run ID as `eventData.conflictingRunId`.
|
|
161
|
+
|
|
162
|
+
Keep the owning run available for at least as long as its token remains unavailable because conflicts return that run.
|
|
163
|
+
|
|
164
|
+
**Automatic hook cleanup:** When a run ends, remove its live hooks. Make each token available unless its `tokenRetentionUntil` is still in the future. A `hook_disposed` event always makes the token available immediately.
|
|
165
|
+
|
|
166
|
+
### Optional: waiting for a terminal run status
|
|
167
|
+
|
|
168
|
+
`await run.returnValue` must determine when a run finishes. Without help, it rereads the run every second, so it reports a run that finishes just after a read up to 1s late. Implement `runs.waitForTerminalStatus(id, { timeoutMs, signal, resolveData })` so the runtime asks once and receives an answer when the run ends.
|
|
169
|
+
|
|
170
|
+
The contract is deliberately forgiving, because "wait" means something different in every store:
|
|
171
|
+
|
|
172
|
+
- Resolve as soon as the run's status is terminal, returning the same entity `get` returns.
|
|
173
|
+
- Resolve no later than roughly `timeoutMs` with the latest snapshot, whatever its status. **A timeout is a normal return, never an error.** A run that is still running is a valid response, and the runtime asks again.
|
|
174
|
+
- `timeoutMs` is an upper bound, not a lower one. You may return a nonterminal snapshot early, and the runtime paces its own retries.
|
|
175
|
+
- Fail exactly like `get`. A missing run throws `WorkflowRunNotFoundError`.
|
|
176
|
+
|
|
177
|
+
Choose a waiting mechanism that fits your store. The reference Worlds use a server-side long poll (`world-vercel` holds `GET /v2/runs/:runId/status` open), `LISTEN`/`NOTIFY` (`world-postgres`), and an in-process emitter over the run files (`world-local`), respectively. Treat the notification only as a *signal*, and reread the run before responding. Back the wait with a periodic reread so a lost notification increases latency instead of causing a hang until the budget expires.
|
|
178
|
+
|
|
179
|
+
Omitting the method is supported. A store with no change notification, or a deterministic simulator such as `world-sim` where a real wait would stall a virtual clock, can leave it out. The runtime continues polling `get` at intervals, and no other behavior degrades. There is no capability to declare.
|
|
180
|
+
|
|
181
|
+
### Event ID allocation
|
|
182
|
+
|
|
183
|
+
<Callout type="warn">
|
|
184
|
+
**Required.** Your World assigns every event ID, and every ID must be a position in its run's log. The runtime reads a position from every ID it loads and fails the run when it cannot. A World whose IDs use another format, such as a ULID, UUID, or database sequence shared across runs, cannot replay a workflow. There is no capability flag or fallback path.
|
|
185
|
+
</Callout>
|
|
186
|
+
|
|
187
|
+
An ID is `evnt_` followed by the event's 1-based position in that run's log, zero-padded to 26 characters, so the run's first event is `evnt_00000000000000000000000001`. Use `slotToEventId()` from `@workflow/world` to format one.
|
|
188
|
+
|
|
189
|
+
Two properties have to hold, and both are about what a reader can conclude from the log:
|
|
190
|
+
|
|
191
|
+
- **Uniqueness.** Two writers racing to append must not both take a position. Settle it where the store settles it, with a unique constraint on `(runId, eventId)` or a conditional write, rather than reading the maximum and adding one in your own process.
|
|
192
|
+
- **Density.** Positions run from 1 with no holes, which is what lets a reader tell a complete log from a truncated one by its length alone. A writer that loses a race must re-derive its position from the store and take the next free one. Incrementing a local number after a loss leaves a permanent hole, and the runtime treats a hole as a log it cannot safely replay across.
|
|
193
|
+
|
|
194
|
+
Allocate the position **at the commit** in the same operation that appends the event. This makes a reader's log a *prefix* of the run's log rather than a prefix with a hole. Nothing can land behind a position that a reader has passed. Assigning a position earlier, such as in a request handler, and committing later breaks the property that every replay depends on. This is the one case where you may need [a stale-write rejection](#optional-rejecting-a-stale-write) to compensate.
|
|
195
|
+
|
|
196
|
+
#### Optional: pre-assigned positions and `noop` sealing
|
|
197
|
+
|
|
198
|
+
Spec version 7 supports one alternative to allocate-at-commit for Worlds whose stores make commit-time allocation a contention bottleneck. Assign positions from a per-run atomic counter **before** the commit, and restore density at read time. With preassignment, concurrent writers hold distinct positions and never race for one. However, a writer that claims a position and stops leaves a permanent hole. A World that allocates this way MUST **seal** provably abandoned positions by writing a `noop` event into them. The seal races the original writer at the same uniqueness fence, and losing that race means the real event landed successfully. The World MUST NOT return a page with an interior hole. Return the dense prefix below the hole, and let the caller's next page continue past it after the position resolves to an event or a seal.
|
|
199
|
+
|
|
200
|
+
The runtime skips `noop` events during replay. It never delivers them to a consumer or uses them to advance the deterministic clock, so a sealed log replays identically to one whose writers filled the holes. `noop` isn't user-creatable and is never sent to `events.create()`. Only your read path may write one. Worlds that allocate at the commit, through a synchronous counter or unique-constraint append, maintain perfect density and don't need sealing. `world-local` and `world-postgres` never seal, and a World that allocates at the commit is spec 7 compliant without additional work.
|
|
201
|
+
|
|
202
|
+
The version a World stamps comes from `mintedSpecVersion()`: 8 by default or the slot-identity version (6) when [`WORKFLOW_SEALED_LOG=0`](/docs/configuration/runtime-tuning#workflow_sealed_log) disables it. Declare `mintedSpecVersion()` instead of a literal so your World moves with the fleet. A runtime other than the one that created a spec 7 run may read it, so readers must understand `noop` before anything stamps 7 in that environment.
|
|
203
|
+
|
|
204
|
+
`events.create()` params carry `eventCount`: how many events the writer held in the log it replayed from, which is the position it expects to land on minus one. Attempt `eventCount + 1`. When that position is taken, **do not reject the write**. Advance to the next free position, commit there, and return the events occupying the positions you skipped over on the success response, in `events` with a matching `cursor` and `hasMore`. The writer merges them into its own log and replays once, rather than paying a second round trip to discover it was behind. A caller with a stale count is the normal case for a fan-out, and rejecting it would serialize writes the runtime issues in parallel.
|
|
205
|
+
|
|
206
|
+
Not every write carries `eventCount`, and a World is free to skip the report read for event types whose writer never holds a log to merge it into (the step executor's `step_started`, `step_retrying`, `step_completed`, and `step_failed`, and the run-terminal `run_completed`, `run_failed`, and `run_cancelled`). Which writes send `eventCount` or `sinceCursor`, and why, is tabulated in [Events returned on a write](/docs/how-it-works/event-sourcing#events-returned-on-a-write). When a write carries both, the `sinceCursor` delta is a superset of the report, so return the delta and skip the report.
|
|
207
|
+
|
|
208
|
+
### Optional: rejecting a stale write
|
|
209
|
+
|
|
210
|
+
A replay writes events derived from the event log it loaded, so a write made from an event log that no longer matches the store can commit events no correct replay would produce. Rejecting one means answering `events.create()` with a `PreconditionFailedError` when the run's log already holds more events than the caller's `eventCount` says it had loaded.
|
|
211
|
+
|
|
212
|
+
No World in this repository does. Reporting is the better mechanism because it removes the need to reject, not only because it costs less. A reader's log is a prefix of the run's log rather than a prefix with a hole in it, replay is deterministic on a prefix, and the writer's next write brings back what it missed. A shorter log therefore means a run that has not caught up, never a run that will decide differently.
|
|
213
|
+
|
|
214
|
+
Implement rejection when your store allocates positions outside the commit and cannot reliably report the skipped span. Refusing a stale write is then safer than accepting one out of order.
|
|
215
|
+
|
|
216
|
+
Two rules make this safe:
|
|
217
|
+
|
|
218
|
+
- **Only reject on evidence.** If your record of a run's events is incomplete, expired, or cannot answer the question, accept the creation. A rejection must always indicate a real discrepancy because the runtime responds by discarding a replay.
|
|
219
|
+
- **Accept a creation that carries no `eventCount`.** It came from a caller with no loaded log that could be stale, such as a queued step body or out-of-band writer, rather than a caller claiming the log is empty.
|
|
220
|
+
|
|
221
|
+
A rejection may include the events the caller was missing as `{ events, cursor }` on the error's `details`. Include them only when you can prove that the set fully accounts for the discrepancy, isn't truncated, and contains only events from the run being written. The runtime merges them directly into the replay's event log, so incorrect data is worse than no delta. Otherwise, omit them, and the runtime performs a full reload.
|
|
222
|
+
|
|
223
|
+
The runtime handles a rejection wherever it arrives. It restarts the replay in-process from a corrected log and invokes a fresh replay after spending that budget. It never retries the rejected write as-is because a replay working from a corrected log derives different events. This behavior requires no declaration or capability.
|
|
224
|
+
|
|
225
|
+
## Queue interface
|
|
226
|
+
|
|
227
|
+
The Queue interface handles asynchronous workflow execution, including queued step work:
|
|
228
|
+
|
|
229
|
+
{/* @skip-typecheck - interface definition, not runnable code */}
|
|
230
|
+
```typescript
|
|
231
|
+
interface Queue {
|
|
232
|
+
getDeploymentId(): Promise<string>;
|
|
233
|
+
|
|
234
|
+
queue(
|
|
235
|
+
queueName: ValidQueueName,
|
|
236
|
+
message: QueuePayload,
|
|
237
|
+
opts?: QueueOptions
|
|
238
|
+
): Promise<{ messageId: MessageId | null }>;
|
|
239
|
+
|
|
240
|
+
// Optional. Omit it and the runtime publishes one message at a time.
|
|
241
|
+
queueBatch?(
|
|
242
|
+
queueName: ValidQueueName,
|
|
243
|
+
messages: readonly { message: QueuePayload; opts?: QueueOptions }[]
|
|
244
|
+
): Promise<QueueBatchResult[]>;
|
|
245
|
+
|
|
246
|
+
// Optional request/response delivery, enabled with capabilities.invoke.
|
|
247
|
+
invoke?(runId: string, payload: unknown, options?: InvokeOptions): Promise<unknown>;
|
|
248
|
+
|
|
249
|
+
createQueueHandler(
|
|
250
|
+
queueNamePrefix: QueuePrefix,
|
|
251
|
+
handler: (message: unknown, meta: { attempt: number; queueName: ValidQueueName; messageId: MessageId }) => Promise<unknown>
|
|
252
|
+
): (req: Request) => Promise<Response>;
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Invocation delivery
|
|
257
|
+
|
|
258
|
+
Implement the optional `invoke` method to send an input to a workflow runner and
|
|
259
|
+
return the runner's response. Enable `capabilities.invoke` only when your World
|
|
260
|
+
supports this operation.
|
|
261
|
+
|
|
262
|
+
Your World must guarantee at most one active workflow runner per `runId` across
|
|
263
|
+
all worker processes. Different runs may execute concurrently. A replacement
|
|
264
|
+
runner can take over after the previous runner stops.
|
|
265
|
+
|
|
266
|
+
Route inputs to the active runner, or start or resume the runner when none is
|
|
267
|
+
active. Call the existing `createQueueHandler` callback with
|
|
268
|
+
`{ runId, invoke: true, requestId, input }`. The callback validates the input,
|
|
269
|
+
waits for required event writes, and returns a result. Deliver that result to the
|
|
270
|
+
original caller. Support input processing while the runner awaits step work,
|
|
271
|
+
without starting a second runner for the same run.
|
|
272
|
+
|
|
273
|
+
`requestId` identifies the logical input across retries and is separate from a
|
|
274
|
+
queue delivery ID. `InvokeOptions` accepts an `idempotencyKey` and a `timeoutMs`
|
|
275
|
+
response-wait limit.
|
|
276
|
+
|
|
277
|
+
Treat invocation return values as response data, including any `timeoutSeconds`
|
|
278
|
+
property. Ordinary workflow wake results use `timeoutSeconds` to schedule another
|
|
279
|
+
execution.
|
|
280
|
+
|
|
281
|
+
You can transport results with `InvocationOutcome`, using `{ ok: true, value }`
|
|
282
|
+
for success or `{ ok: false, error: SerializedWorkflowError }` for an error. The
|
|
283
|
+
helpers in `@workflow/errors/invocation` capture handler results and restore
|
|
284
|
+
errors with their known Workflow classes and diagnostic fields. Return the value
|
|
285
|
+
or throw the restored error from `invoke()`.
|
|
286
|
+
|
|
287
|
+
Keep failures to store or deliver a response distinct from handler errors. A
|
|
288
|
+
transport failure leaves processing unknown, and a handler error may follow
|
|
289
|
+
committed event writes. If your World advertises `hookResumeDedup`, enforce that
|
|
290
|
+
capability when retrying a hook event whose response was lost.
|
|
291
|
+
|
|
292
|
+
### Batched publishing
|
|
293
|
+
|
|
294
|
+
`queueBatch` is optional. Implement it when your transport can accept several messages in one round trip, and the runtime will use it to dispatch a wide `Promise.all` fan-out. A fan-out otherwise costs one round trip per branch, paid before the first branch's step body runs, so it lands directly on time-to-first-step.
|
|
295
|
+
|
|
296
|
+
Its contract differs from `queue` in one important way: **a message that fails is reported, not thrown.** Return one result per input message, in input order, where `error` is set on the entries that were rejected and `retryable` says whether republishing might succeed. Reject the promise only for a request-level failure, where you cannot say what happened to any individual message.
|
|
297
|
+
|
|
298
|
+
The count must match: return exactly as many results as you were given messages. The runtime rejects the whole batch if it does not, because an omitted result is indistinguishable from a message that was never published, and treating it as success would strand that step with nothing reporting an error.
|
|
299
|
+
|
|
300
|
+
{/* @skip-typecheck - interface definition, not runnable code */}
|
|
301
|
+
```typescript
|
|
302
|
+
type QueueBatchResult =
|
|
303
|
+
| { messageId: MessageId | null; error?: undefined }
|
|
304
|
+
| { messageId: null; error: string; retryable: boolean };
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
`messageId: null` with no `error` means accepted without an ID yet, exactly as for `queue`, so callers test `error === undefined` for success rather than a non-null `messageId`.
|
|
308
|
+
|
|
309
|
+
Every message targets one logical `queueName`, but you may split the batch internally — by a transport cap, or by any per-message routing dimension you derive from the payload — as long as the returned order still matches the input. The runtime passes a distinct `idempotencyKey` per message, because its recovery for both a request-level failure and a retryable entry is to republish the whole batch.
|
|
310
|
+
|
|
311
|
+
### Queue names
|
|
312
|
+
|
|
313
|
+
Queue names follow a specific pattern:
|
|
314
|
+
- `__wkf_workflow_<name>`: Used for workflow orchestration and queued step invocations
|
|
315
|
+
|
|
316
|
+
### Message payloads
|
|
317
|
+
|
|
318
|
+
Workflow orchestration and step execution use the same payload. `stepId` and `stepName` identify a queued step invocation:
|
|
319
|
+
|
|
320
|
+
**Workflow Invocations:**
|
|
321
|
+
{/* @skip-typecheck - interface definition, not runnable code */}
|
|
322
|
+
```typescript
|
|
323
|
+
interface WorkflowInvokePayload {
|
|
324
|
+
runId: string;
|
|
325
|
+
stepId?: string;
|
|
326
|
+
stepName?: string;
|
|
327
|
+
traceCarrier?: Record<string, string>; // OpenTelemetry context
|
|
328
|
+
requestedAt?: Date;
|
|
329
|
+
}
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
The SDK also sends an internal `HealthCheckPayload` through the same workflow queue.
|
|
333
|
+
|
|
334
|
+
### Implementation considerations
|
|
335
|
+
|
|
336
|
+
- Messages must be delivered at-least-once
|
|
337
|
+
- Support configurable retry policies
|
|
338
|
+
- Track attempt counts for observability
|
|
339
|
+
- Implement idempotency using the `idempotencyKey` option when provided
|
|
340
|
+
- Honor the `delaySeconds` option. Waits (`sleep()`) are delivered as ordinary delayed continuations on the workflow queue, so a World that ignores `delaySeconds` redelivers immediately and causes every sleeping run to enter a busy loop
|
|
341
|
+
- Optionally honor the `region` option, a routing hint naming the region a message should be dispatched in; Worlds without a regional dimension ignore it
|
|
342
|
+
|
|
343
|
+
## Streamer interface
|
|
344
|
+
|
|
345
|
+
The Streamer interface enables real-time data streaming:
|
|
346
|
+
|
|
347
|
+
{/* @skip-typecheck - interface definition, not runnable code */}
|
|
348
|
+
```typescript
|
|
349
|
+
interface Streamer {
|
|
350
|
+
streamFlushIntervalMs?: number;
|
|
351
|
+
|
|
352
|
+
streams: {
|
|
353
|
+
write(
|
|
354
|
+
runId: string,
|
|
355
|
+
name: string,
|
|
356
|
+
chunk: string | Uint8Array
|
|
357
|
+
): Promise<void>;
|
|
358
|
+
|
|
359
|
+
writeMulti?(
|
|
360
|
+
runId: string,
|
|
361
|
+
name: string,
|
|
362
|
+
chunks: (string | Uint8Array)[]
|
|
363
|
+
): Promise<void>;
|
|
364
|
+
|
|
365
|
+
close(runId: string, name: string): Promise<void>;
|
|
366
|
+
|
|
367
|
+
get(
|
|
368
|
+
runId: string,
|
|
369
|
+
name: string,
|
|
370
|
+
startIndex?: number
|
|
371
|
+
): Promise<ReadableStream<Uint8Array>>;
|
|
372
|
+
|
|
373
|
+
list(runId: string): Promise<string[]>;
|
|
374
|
+
|
|
375
|
+
/** Paginated snapshot of stream chunks. */
|
|
376
|
+
getChunks(
|
|
377
|
+
runId: string,
|
|
378
|
+
name: string,
|
|
379
|
+
options?: { limit?: number; cursor?: string }
|
|
380
|
+
): Promise<{
|
|
381
|
+
data: { index: number; data: Uint8Array }[];
|
|
382
|
+
cursor: string | null;
|
|
383
|
+
hasMore: boolean;
|
|
384
|
+
done: boolean;
|
|
385
|
+
}>;
|
|
386
|
+
|
|
387
|
+
/** Lightweight metadata: tail index and completion flag. */
|
|
388
|
+
getInfo(
|
|
389
|
+
runId: string,
|
|
390
|
+
name: string
|
|
391
|
+
): Promise<{ tailIndex: number; done: boolean }>;
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
Streams are identified by a combination of `runId` and `name`. Each workflow run can have multiple named streams.
|
|
397
|
+
`writeMulti()` is an optional optimization for batching multiple writes.
|
|
398
|
+
|
|
399
|
+
`getChunks` returns a paginated snapshot of currently available chunks, unlike `get`, which returns a live `ReadableStream` that waits for new chunks. `getInfo` returns the tail index (last chunk index, 0-based, or `-1` when empty) and whether the stream is complete. Use this information to resolve negative `startIndex` values into absolute positions.
|
|
400
|
+
|
|
401
|
+
## Analytics interface (optional)
|
|
402
|
+
|
|
403
|
+
The optional `analytics` namespace provides **metadata-only** access to runs and their related records. It is intended for observability and discovery surfaces such as dashboards, `workflow inspect`, and the local web UI. Implementations can optimize these queries independently of payload storage.
|
|
404
|
+
|
|
405
|
+
Tooling detects whether this namespace is available. When `world.analytics` is available, tooling prefers it for listings and attribute search. Otherwise, it uses the Storage APIs. Among the first-party Worlds, only the [Vercel World](/worlds/vercel) currently implements it. The Local and Postgres Worlds leave it `undefined`.
|
|
406
|
+
|
|
407
|
+
{/* @skip-typecheck - interface definition, not runnable code */}
|
|
408
|
+
```typescript
|
|
409
|
+
interface Analytics {
|
|
410
|
+
runs: {
|
|
411
|
+
get(runId: string): Promise<AnalyticsRun>;
|
|
412
|
+
list(params?: AnalyticsListRunsParams): Promise<PaginatedResponse<AnalyticsRun>>;
|
|
413
|
+
};
|
|
414
|
+
attributes: {
|
|
415
|
+
// Distinct attribute keys observed on runs in the window, with run
|
|
416
|
+
// counts and first/last-seen timestamps, ordered alphabetically.
|
|
417
|
+
list(params?: AnalyticsListAttributesParams): Promise<PaginatedResponse<AnalyticsAttributeKey>>;
|
|
418
|
+
};
|
|
419
|
+
steps: {
|
|
420
|
+
get(runId: string, stepId: string): Promise<AnalyticsStep>;
|
|
421
|
+
list(params: AnalyticsListRunScopedParams): Promise<PaginatedResponse<AnalyticsStep>>;
|
|
422
|
+
};
|
|
423
|
+
events: {
|
|
424
|
+
get(runId: string, eventId: string): Promise<AnalyticsEvent>;
|
|
425
|
+
list(params: AnalyticsListEventsParams): Promise<PaginatedResponse<AnalyticsEvent>>;
|
|
426
|
+
listByCorrelationId(params: AnalyticsListEventsByCorrelationIdParams): Promise<PaginatedResponse<AnalyticsEvent>>;
|
|
427
|
+
};
|
|
428
|
+
hooks: {
|
|
429
|
+
get(hookId: string, params?: { runId?: string }): Promise<AnalyticsHook>;
|
|
430
|
+
list(params: AnalyticsListHooksParams): Promise<PaginatedResponse<AnalyticsHook>>;
|
|
431
|
+
};
|
|
432
|
+
waits: {
|
|
433
|
+
get(runId: string, waitId: string): Promise<AnalyticsWait>;
|
|
434
|
+
list(params: AnalyticsListWaitsParams): Promise<PaginatedResponse<AnalyticsWait>>;
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
If you implement this namespace, observe the following requirements:
|
|
440
|
+
|
|
441
|
+
- **Metadata only.** Analytics responses must not include run inputs or outputs, step data, hook tokens, or other payload data. The `Analytics*` schemas exported by `@workflow/world` define the complete set of permitted fields. Payload retrieval remains exclusively available through the Storage APIs.
|
|
442
|
+
- **Attribute filters use the latest value.** `runs.list({ attributes })` evaluates each filter against the run's most recently written value for that key. A request may contain up to eight key-value pairs. Reserved `$`-prefixed attributes are valid filters, although users cannot write them directly.
|
|
443
|
+
- **Time boundaries must be paired.** `startTime` and `endTime` may either both be omitted or both be supplied. Responses may include `pageInfo` describing retention and the available query window. Implementations with retention limits should return this information so tooling can present valid date ranges.
|
|
444
|
+
- **Results may be eventually consistent.** Analytics records may lag live workflow state. Consumers use this namespace for discovery and listing; Storage remains the authoritative interface for current workflow state and payload access.
|
|
445
|
+
|
|
446
|
+
See the [Analytics API reference](/docs/api-reference/workflow-runtime/world/analytics) for per-method parameters, row shapes, and `pageInfo` semantics.
|
|
447
|
+
|
|
448
|
+
## Process-wide state
|
|
449
|
+
|
|
450
|
+
Hold state that must be process-wide on `globalThis`, not at module scope.
|
|
451
|
+
|
|
452
|
+
A World is loaded in one of two ways, and only one of them gives your package a
|
|
453
|
+
single module instance:
|
|
454
|
+
|
|
455
|
+
- **Loaded at runtime.** `WORKFLOW_TARGET_WORLD=@your-org/world-foo` is resolved
|
|
456
|
+
with `require()` at runtime, so Node's module cache dedupes it and one process
|
|
457
|
+
holds one copy.
|
|
458
|
+
- **Bundled.** The host application's bundler compiles your package into its
|
|
459
|
+
server build. Bundlers key module identity on `(resource, layer)`, and a
|
|
460
|
+
framework routinely builds several server layers. Next.js compiles
|
|
461
|
+
`instrument`, app-route, `ssr` and `edge` as separate module graphs. Your
|
|
462
|
+
package is then compiled into each one, so a single process holds several
|
|
463
|
+
copies of every one of your modules, each with its own module scope.
|
|
464
|
+
|
|
465
|
+
The two built-in worlds are bundled. A custom world is not today, but that is a
|
|
466
|
+
property of how it is loaded rather than of how it is written, and it can change
|
|
467
|
+
under you. `@workflow/world-vercel` was external until it wasn't, and every
|
|
468
|
+
module-scope variable in it silently became per-copy state.
|
|
469
|
+
|
|
470
|
+
So a top-level `let` or a `const` holding a `Map` is not the singleton it looks
|
|
471
|
+
like:
|
|
472
|
+
|
|
473
|
+
```typescript
|
|
474
|
+
// Wrong: one Map per copy. Writes from one part of the app are invisible to
|
|
475
|
+
// another, and a mutex like this simply stops mutually excluding.
|
|
476
|
+
const locks = new Map<string, Promise<void>>();
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
Reach for `globalThis` under a `Symbol.for()` key instead, so every copy shares
|
|
480
|
+
one object:
|
|
481
|
+
|
|
482
|
+
```typescript
|
|
483
|
+
type WorldState = { locks: Map<string, Promise<void>> };
|
|
484
|
+
|
|
485
|
+
const StateKey = Symbol.for('@your-org/world-foo//locks/v1');
|
|
486
|
+
const store = globalThis as typeof globalThis &
|
|
487
|
+
Record<symbol, WorldState | undefined>;
|
|
488
|
+
|
|
489
|
+
const state: WorldState = (store[StateKey] ??= { locks: new Map() });
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
Version the key. Two releases of your package can end up in one process, and a
|
|
493
|
+
key without a version lets an older copy read a state object it does not
|
|
494
|
+
understand.
|
|
495
|
+
|
|
496
|
+
Inside this repository, `globalSingleton()` from `@workflow/utils` does exactly
|
|
497
|
+
this and is what the first-party worlds use; the hand-rolled form above is
|
|
498
|
+
written out so a world published outside this repository does not need the
|
|
499
|
+
dependency. `scripts/lint/module-scope-state.mjs` accepts either.
|
|
500
|
+
|
|
501
|
+
Better still, keep the state on the World instance your `createWorld()` returns.
|
|
502
|
+
Connection pools, caches, and open channels are usually per-World rather than
|
|
503
|
+
per-process, and instance state cannot be duplicated by a bundler. Reserve the
|
|
504
|
+
global for the few things that are genuinely process-wide: ID generators whose
|
|
505
|
+
sequence must not fork, and log-once latches.
|
|
506
|
+
|
|
507
|
+
## Reference implementations
|
|
508
|
+
|
|
509
|
+
Study these implementations for guidance:
|
|
510
|
+
|
|
511
|
+
- **[Local World](https://github.com/vercel/workflow/tree/main/packages/world-local)**: A file-system-based implementation for understanding the basics
|
|
512
|
+
- **[Postgres World](https://github.com/vercel/workflow/tree/main/packages/world-postgres)**: A database-backed implementation with Graphile Worker for queuing
|
|
513
|
+
|
|
514
|
+
## Testing your World
|
|
515
|
+
|
|
516
|
+
Workflow SDK includes an E2E test suite that validates World implementations. Once your World is published to npm:
|
|
517
|
+
|
|
518
|
+
1. Add your world to [`worlds-manifest.json`](https://github.com/vercel/workflow/blob/main/worlds-manifest.json)
|
|
519
|
+
2. Open a PR to the Workflow repository
|
|
520
|
+
3. CI will automatically run the E2E test suite against your implementation
|
|
521
|
+
|
|
522
|
+
Your World will then appear on the [Worlds ecosystem](/worlds) page with its compatibility status and performance benchmarks.
|
|
523
|
+
|
|
524
|
+
## Publishing your World
|
|
525
|
+
|
|
526
|
+
1. **Package your World**: Export a default World instance from your package
|
|
527
|
+
2. **Publish to npm**: Publish your package to npm
|
|
528
|
+
3. **Add to the manifest**: Submit a PR adding your World to [`worlds-manifest.json`](https://github.com/vercel/workflow/blob/main/worlds-manifest.json)
|
|
529
|
+
4. **Document configuration**: Document any required environment variables
|
|
530
|
+
|
|
531
|
+
```json
|
|
532
|
+
// worlds-manifest.json entry
|
|
533
|
+
{
|
|
534
|
+
"package": "your-world-package",
|
|
535
|
+
"repository": "https://github.com/you/your-world",
|
|
536
|
+
"docs": "https://github.com/you/your-world#readme"
|
|
537
|
+
}
|
|
538
|
+
```
|