Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/lazy-hook-metadata-getter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@workflow/core': major
'workflow': major
'@workflow/world-testing': patch
---

**Breaking:** `hook.metadata` on hooks returned by `getHookByToken()` and `resumeHook()` is now a lazy getter that returns a Promise, like `run.returnValue`, and needs to be awaited. Looking a hook up by token no longer pays for hydrating metadata that is never read.
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ When `experimental_minRetention` is set, this function continues to return the H
`getHookByToken` is a runtime function that must be called from outside a workflow function.
</Callout>

<Callout type="info">
`hook.metadata` is a getter that returns a Promise, so `await` it to read the value. Hydrating metadata can add extra network round trips, so that work is deferred to first access and the lookup itself stays a single read. Awaiting it on a hook with no metadata resolves `undefined` and performs no extra work, and repeat reads are free.
</Callout>

<Callout type="info">
Looking up a deterministic hook token is useful in hook-based idempotency flows, but it is only an advisory check. If no hook exists yet, another request can still start the same workflow before your `start()` call registers its hook. Use the lookup to avoid obvious duplicate starts, and handle the race inside the workflow by checking `await hook.getConflict()` before duplicate-sensitive work. On a conflict it resolves with the run that owns the token, so the duplicate can route the caller to the active owner. If duplicates must be rejected before a workflow body runs, keep a durable request record until native atomic start-and-hook registration exists. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
</Callout>
Expand Down Expand Up @@ -48,7 +52,7 @@ Returns a `Promise<Hook>` that resolves to:

<TSDoc
definition={`
import type { Hook } from "@workflow/world";
import type { Hook } from "workflow/api";
export default Hook;`}
showSections={["returns"]}
/>
Expand All @@ -70,7 +74,7 @@ export async function POST(request: Request) {
const hook = await getHookByToken(token); // [!code highlight]

console.log("Resuming workflow run:", hook.runId);
console.log("Hook metadata:", hook.metadata);
console.log("Hook metadata:", await hook.metadata); // [!code highlight]

// Then resume the hook with the payload
await resumeHook(token, data);
Expand All @@ -97,7 +101,8 @@ export async function POST(request: Request) {

try {
const hook = await getHookByToken(token); // [!code highlight]
const metadata = hook.metadata as { allowedUserId?: string } | undefined;
// `metadata` is a Promise, so awaiting it hydrates the stored value.
const metadata = (await hook.metadata) as { allowedUserId?: string } | undefined; // [!code highlight]

// Validate that the hook metadata matches the user
if (metadata?.allowedUserId !== userId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,11 @@ showSections={["parameters"]}

### Returns

Returns a `Promise<ResumedHook>`, a `Hook` extended with an optional `resilientResume` flag. Resolving means the payload is durably recorded as `hook_received` and the workflow wake was accepted. `resilientResume` is retained for source compatibility and is no longer set by any path. The resolved hook:
Returns a `Promise<ResumedHook>`, a `Hook` (from `workflow/api`) extended with an optional `resilientResume` flag. Resolving means the payload is durably recorded as `hook_received` and the workflow wake was accepted. `resilientResume` is retained for source compatibility and is no longer set by any path. Resuming never reads the hook's metadata, so the resolved hook's `metadata` is a Promise that hydrates on first access, exactly as with [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token): `await hook.metadata` to read it. The resolved hook:

<TSDoc
definition={`
import type { Hook } from "@workflow/world";
import type { Hook } from "workflow/api";
export default Hook;`}
showSections={["returns"]}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -444,9 +444,11 @@ import { getWorld } from "workflow/runtime";

const world = await getWorld();
const hook = await world.hooks.getByToken(token); // [!code highlight]
console.log(hook.runId, hook.metadata); // [!code highlight]
console.log(hook.runId); // [!code highlight]
```

The World-level `Hook` carries `metadata` as the raw serialized (and, on encrypting Worlds, encrypted) value, not the object the workflow passed to `createHook()`. To read the decoded value, use [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token), whose `hook.metadata` is a Promise that hydrates it on first access.

### List events for audit trail

```typescript lineNumbers
Expand Down
1 change: 1 addition & 0 deletions docs/content/docs/v5/meta.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"pages": [
"---",
"whats-new",
"getting-started",
"foundations",
"how-it-works",
Expand Down
1 change: 1 addition & 0 deletions docs/content/docs/v5/whats-new.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ All three first-party Worlds now implement it: Vercel accepts up to 30 days, and
| --- | --- |
| `runStep` removed from `workflow/api` | Call your step function directly; the compiler routes it through the step runtime. |
| `hook.getConflict()` resolves with a `Run` | Replace `conflict.runId` round trips through `getRun()` inside a step with the accessors on `conflict` directly. `conflict.runId` still works. |
| `hook.metadata` is a Promise on hooks returned by `getHookByToken()` and `resumeHook()` | Write `await hook.metadata` where you read it. Hydrating metadata can add network round trips, and those are now paid only by code that reads it, so the lookup itself is a single read. The `Hook` type is exported from `workflow/api`; the World-level record from `world.hooks.getByToken()` is unchanged. See [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token). |
| `experimental_setAttributes` removed | Import `setAttributes` instead, and `SetAttributesOptions` in place of `ExperimentalSetAttributesOptions`. The deprecated aliases are gone. |
| [`getWorld()`](/docs/api-reference/workflow-runtime/get-world), [`getWorldHandlers()`](/docs/api-reference/workflow-runtime/get-world-handlers), and [`createWorld()`](/docs/api-reference/workflow-runtime/create-world) are async | They resolve a `Promise` now, so await the call before reaching for anything on it: `const world = await getWorld();` then `await world.start?.();`. This mainly affects the `instrumentation.ts` bootstrap that starts a World with background workers, such as the [Postgres World](/worlds/postgres#starting-the-world). Under TypeScript the old shape fails the build; in plain JavaScript it does not, and `.start` reads as `undefined` on a promise, so the worker never starts and nothing is logged. Writing `await getWorld()` is also valid on 4.x, so the change can be made before upgrading. |
| Duplicate step or workflow IDs fail the build | 4.x resolved collisions across non-exported workspace files last-write-wins. If you start encountering build failures after upgrading, rename the colliding functions. |
Expand Down
6 changes: 3 additions & 3 deletions packages/core/e2e/e2e-region.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,9 +446,9 @@ describe.skipIf(isLocalDeployment())('multi-region (world-vercel)', () => {
// Resolve by opaque token from the test process.
const hook = await waitForHook(token, run.runId);
expect(hook.runId).toBe(run.runId);
expect((hook.metadata as { customData?: string })?.customData).toBe(
label
);
expect(
((await hook.metadata) as { customData?: string })?.customData
).toBe(label);

// Resume the suspended run by token — twice, sequentially, so
// the payload order in the run's event log is deterministic.
Expand Down
20 changes: 10 additions & 10 deletions packages/core/e2e/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -687,7 +687,7 @@ describe.concurrent('e2e', () => {
expect(hook.runId).toBe(run.runId);
await resumeHook(hook, {
message: 'one',
customData: (hook.metadata as any)?.customData,
customData: ((await hook.metadata) as any)?.customData,
});

// Invalid token test
Expand All @@ -698,7 +698,7 @@ describe.concurrent('e2e', () => {
expect(hook.runId).toBe(run.runId);
await resumeHook(hook, {
message: 'two',
customData: (hook.metadata as any)?.customData,
customData: ((await hook.metadata) as any)?.customData,
});

// Resume with third (final) payload
Expand All @@ -707,7 +707,7 @@ describe.concurrent('e2e', () => {
await resumeHook(hook, {
message: 'three',
done: true,
customData: (hook.metadata as any)?.customData,
customData: ((await hook.metadata) as any)?.customData,
});

const returnValue = await run.returnValue;
Expand Down Expand Up @@ -748,7 +748,7 @@ describe.concurrent('e2e', () => {
// Now resume via server-side resumeHook() — should work
await resumeHook(hook, {
message: 'via-server',
customData: (hook.metadata as any)?.customData,
customData: ((await hook.metadata) as any)?.customData,
done: true,
});

Expand Down Expand Up @@ -2067,7 +2067,7 @@ describe.concurrent('e2e', () => {
expect(hook.runId).toBe(run1.runId);
await resumeHook(hook, {
message: 'test-message-1',
customData: (hook.metadata as any)?.customData,
customData: ((await hook.metadata) as any)?.customData,
});

// Get first workflow result
Expand All @@ -2091,7 +2091,7 @@ describe.concurrent('e2e', () => {
expect(hook.runId).toBe(run2.runId);
await resumeHook(hook, {
message: 'test-message-2',
customData: (hook.metadata as any)?.customData,
customData: ((await hook.metadata) as any)?.customData,
});

// Get second workflow result
Expand Down Expand Up @@ -2153,7 +2153,7 @@ describe.concurrent('e2e', () => {
const hook = await getHookByToken(token);
await resumeHook(hook, {
message: 'test-concurrent',
customData: (hook.metadata as any)?.customData,
customData: ((await hook.metadata) as any)?.customData,
});

// Verify workflow 1 completed successfully
Expand Down Expand Up @@ -2310,7 +2310,7 @@ describe.concurrent('e2e', () => {

await resumeHook(hook, {
message: 'ready-conflict-holder',
customData: (hook.metadata as any)?.customData,
customData: ((await hook.metadata) as any)?.customData,
});

const run1Result = await run1.returnValue;
Expand Down Expand Up @@ -2648,7 +2648,7 @@ describe.concurrent('e2e', () => {
// Send payload to first workflow - this will trigger it to dispose the hook
await resumeHook(hook, {
message: 'first-payload',
customData: (hook.metadata as any)?.customData,
customData: ((await hook.metadata) as any)?.customData,
});

// Wait for workflow 1 to release the token before starting workflow 2.
Expand All @@ -2670,7 +2670,7 @@ describe.concurrent('e2e', () => {
// Send payload to workflow 2
await resumeHook(hook, {
message: 'second-payload',
customData: (hook.metadata as any)?.customData,
customData: ((await hook.metadata) as any)?.customData,
});

// Wait for both workflows to complete
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/create-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,9 @@ export interface HookOptions {
/**
* Additional user-defined data to include with the hook payload.
*
* Read it back outside the workflow with `getHookByToken()`, where
* `hook.metadata` is a Promise: `await hook.metadata`.
*
* @example
*
* ```ts
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export {
} from './runtime/helpers.js';
export {
getHookByToken,
type Hook,
type ResumedHook,
resumeHook,
resumeWebhook,
Expand Down
Loading
Loading