Skip to content
Open
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` is now a lazy getter that returns a Promise, like `run.returnValue` — `await hook.metadata` to read it, and the accessor is non-enumerable, so it is absent from `{ ...hook }` and `JSON.stringify(hook)`. That makes `getHookByToken()` a single read (the run fetch and `run-key` round trip hydration needs are only paid by callers that access metadata, so hook resumption stops paying them), and `resumeHook()`'s returned hook now hydrates metadata instead of exposing raw serialized bytes.
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 decrypts it with the owning run's keys, which can cost a run fetch and a key round trip, so the 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. Like `run.returnValue`, the getter is non-enumerable: it does not appear in `{ ...hook }` or `JSON.stringify(hook)`, so forward the awaited value explicitly if you need to serialize it.
</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 @@ -44,12 +48,12 @@ showSections={["parameters"]}

### Returns

Returns a `Promise<Hook>` that resolves to:
Returns a `Promise<HookWithLazyMetadata>` that resolves to:

<TSDoc
definition={`
import type { Hook } from "@workflow/world";
export default Hook;`}
import type { HookWithLazyMetadata } from "workflow/api";
export default HookWithLazyMetadata;`}
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
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
@@ -1,3 +1,3 @@
import fs from 'node:fs';
import path from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
Expand Down Expand Up @@ -676,7 +676,7 @@
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 @@ -687,7 +687,7 @@
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 @@ -696,7 +696,7 @@
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 @@ -737,7 +737,7 @@
// 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 @@ -2056,7 +2056,7 @@
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 @@ -2080,7 +2080,7 @@
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 @@ -2142,7 +2142,7 @@
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 @@ -2299,7 +2299,7 @@

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 @@ -2637,7 +2637,7 @@
// 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 @@ -2659,7 +2659,7 @@
// 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 HookWithLazyMetadata,
type ResumedHook,
resumeHook,
resumeWebhook,
Expand Down
Loading
Loading