Skip to content

Commit 6aa1ce0

Browse files
[world-vercel] Send remoteRefBehavior=lazy on v4 metadata-only event listings (#2415)
1 parent da37349 commit 6aa1ce0

4 files changed

Lines changed: 124 additions & 6 deletions

File tree

‎.changeset/v4-lazy-list-refs.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@workflow/world-vercel": patch
3+
---
4+
5+
Skip transferring event payload bytes when listing events with `resolveData: 'none'` using the v4 API.

‎packages/world-vercel/src/events-v4.ts‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,15 @@ export interface ListEventsV4Params {
362362
cursor?: string;
363363
limit?: number;
364364
sortOrder?: 'asc' | 'desc';
365+
/**
366+
* Whether the backend resolves payload bytes into each frame body.
367+
* `resolve` (default) streams the bytes; `lazy` emits empty-body frames
368+
* (the ref descriptor stays in the frame meta) — for metadata-only
369+
* listings that would otherwise download every payload just to discard
370+
* it. A backend that predates this flag ignores it and streams full
371+
* bodies, so callers must still tolerate bodies being present.
372+
*/
373+
remoteRefBehavior?: 'resolve' | 'lazy';
365374
}
366375

367376
/**
@@ -460,11 +469,23 @@ async function consumeListFrameStream(
460469
return { events, ...(next ? { next } : {}) };
461470
}
462471

463-
function paginationToQuery(params: ListEventsV4Params): string {
464-
const sp = new URLSearchParams();
472+
/**
473+
* Append the shared list params (pagination + ref behavior) to `sp`.
474+
* Shared by the runId and correlationId list query builders so both send
475+
* `remoteRefBehavior` identically.
476+
*/
477+
function appendListParams(sp: URLSearchParams, params: ListEventsV4Params) {
465478
if (params.cursor) sp.set('cursor', params.cursor);
466479
if (params.limit !== undefined) sp.set('limit', String(params.limit));
467480
if (params.sortOrder) sp.set('sortOrder', params.sortOrder);
481+
if (params.remoteRefBehavior) {
482+
sp.set('remoteRefBehavior', params.remoteRefBehavior);
483+
}
484+
}
485+
486+
function paginationToQuery(params: ListEventsV4Params): string {
487+
const sp = new URLSearchParams();
488+
appendListParams(sp, params);
468489
const qs = sp.toString();
469490
return qs ? `?${qs}` : '';
470491
}
@@ -511,9 +532,7 @@ export async function getEventsByCorrelationIdV4(
511532
const { baseUrl, headers } = await getHttpConfig(config);
512533
const sp = new URLSearchParams();
513534
sp.set('correlationId', correlationId);
514-
if (params.cursor) sp.set('cursor', params.cursor);
515-
if (params.limit !== undefined) sp.set('limit', String(params.limit));
516-
if (params.sortOrder) sp.set('sortOrder', params.sortOrder);
535+
appendListParams(sp, params);
517536
const url = `${baseUrl}/v4/events?${sp.toString()}`;
518537
return consumeListFrameStream(
519538
url,

‎packages/world-vercel/src/events.test.ts‎

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import type { AnyEventRequest } from '@workflow/world';
22
import { encode } from 'cbor-x';
33
import { MockAgent } from 'undici';
44
import { describe, expect, it } from 'vitest';
5-
import { createWorkflowRunEvent, splitEventDataForV4 } from './events.js';
5+
import {
6+
createWorkflowRunEvent,
7+
getWorkflowRunEvents,
8+
splitEventDataForV4,
9+
} from './events.js';
10+
import { encodeFrame, V4_FRAME_CONTENT_TYPE } from './frames.js';
611

712
const ORIGIN = 'https://vercel-workflow.com';
813

@@ -344,3 +349,82 @@ describe('createWorkflowRunEvent resolveData', () => {
344349
agent.assertNoPendingInterceptors();
345350
});
346351
});
352+
353+
describe('getWorkflowRunEvents remoteRefBehavior mapping', () => {
354+
// A v4 LIST response: one run_created frame (with payload body) + sentinel.
355+
function listResponse(body: Uint8Array): Buffer {
356+
return Buffer.concat([
357+
encodeFrame(
358+
{
359+
eventId: 'evnt_1',
360+
runId: 'wrun_1',
361+
eventType: 'run_created',
362+
createdAt: '2026-06-10T00:00:00.000Z',
363+
eventData: {
364+
input: { _type: 'RemoteRef', _ref: 's3rf:wrun_1/input' },
365+
workflowName: 'wf',
366+
},
367+
},
368+
body
369+
),
370+
encodeFrame({ _end: 1 }, new Uint8Array(0)),
371+
]);
372+
}
373+
374+
it("sends remoteRefBehavior=lazy for resolveData 'none' and strips any returned body", async () => {
375+
const agent = mockAgent();
376+
// The interceptor only matches when the request carries
377+
// ?remoteRefBehavior=lazy — so a missing/wrong param fails the request.
378+
// The reply still includes payload bytes, simulating a backend that
379+
// predates the flag: the adapter must strip them regardless.
380+
agent
381+
.get(ORIGIN)
382+
.intercept({
383+
path: '/api/v4/runs/wrun_1/events',
384+
method: 'GET',
385+
query: { remoteRefBehavior: 'lazy' },
386+
})
387+
.reply(200, listResponse(new TextEncoder().encode('"payload"')), {
388+
headers: { 'content-type': V4_FRAME_CONTENT_TYPE },
389+
});
390+
391+
const result = await getWorkflowRunEvents(
392+
{ runId: 'wrun_1', resolveData: 'none' },
393+
{ token: 'test-token', dispatcher: agent }
394+
);
395+
396+
const eventData = (
397+
result.data[0] as { eventData?: Record<string, unknown> }
398+
).eventData;
399+
expect(eventData?.input).toBeUndefined();
400+
expect(eventData?.workflowName).toBe('wf');
401+
agent.assertNoPendingInterceptors();
402+
});
403+
404+
it('sends remoteRefBehavior=resolve by default and splices the body bytes', async () => {
405+
const agent = mockAgent();
406+
const body = new TextEncoder().encode('"payload"');
407+
agent
408+
.get(ORIGIN)
409+
.intercept({
410+
path: '/api/v4/runs/wrun_1/events',
411+
method: 'GET',
412+
query: { remoteRefBehavior: 'resolve' },
413+
})
414+
.reply(200, listResponse(body), {
415+
headers: { 'content-type': V4_FRAME_CONTENT_TYPE },
416+
});
417+
418+
// No resolveData → defaults to 'all' → resolve.
419+
const result = await getWorkflowRunEvents(
420+
{ runId: 'wrun_1' },
421+
{ token: 'test-token', dispatcher: agent }
422+
);
423+
424+
const eventData = (
425+
result.data[0] as { eventData?: Record<string, unknown> }
426+
).eventData;
427+
expect(eventData?.input).toEqual(body);
428+
agent.assertNoPendingInterceptors();
429+
});
430+
});

‎packages/world-vercel/src/events.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,10 +460,20 @@ export async function getWorkflowRunEvents(
460460
config?: APIConfig
461461
): Promise<PaginatedResponse<Event>> {
462462
const { pagination, resolveData = DEFAULT_RESOLVE_DATA_OPTION } = params;
463+
// `resolveData: 'none'` means the caller only wants metadata — it discards
464+
// payloads in buildEventFromV4 below. Tell the backend not to stream them
465+
// in the first place (lazy → empty frame bodies). On `'all'` we resolve
466+
// (the default). A backend that predates this flag ignores it and streams
467+
// full bodies regardless; buildEventFromV4 still strips them when
468+
// resolveData is 'none', so this is purely a bandwidth optimization and is
469+
// safe against an older backend.
463470
const wirePagination = {
464471
cursor: pagination?.cursor ?? undefined,
465472
limit: pagination?.limit,
466473
sortOrder: pagination?.sortOrder,
474+
remoteRefBehavior: (resolveData === 'none' ? 'lazy' : 'resolve') as
475+
| 'lazy'
476+
| 'resolve',
467477
};
468478

469479
const result = await ('correlationId' in params

0 commit comments

Comments
 (0)