From 343ce77c92427de221f0b5d3c08f35ffd7664dd6 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:41:01 -0700 Subject: [PATCH 01/10] feat(world-postgres): retain hook tokens after runs end --- .changeset/postgres-hook-min-retention.md | 5 + packages/core/e2e/e2e.test.ts | 5 +- .../0018_add_hook_token_retention.sql | 1 + .../src/drizzle/migrations/meta/_journal.json | 7 ++ packages/world-postgres/src/drizzle/schema.ts | 7 +- packages/world-postgres/src/index.ts | 1 + packages/world-postgres/src/storage.ts | 114 ++++++++++++------ packages/world-postgres/test/storage.test.ts | 106 ++++++++++++++-- 8 files changed, 194 insertions(+), 52 deletions(-) create mode 100644 .changeset/postgres-hook-min-retention.md create mode 100644 packages/world-postgres/src/drizzle/migrations/0018_add_hook_token_retention.sql diff --git a/.changeset/postgres-hook-min-retention.md b/.changeset/postgres-hook-min-retention.md new file mode 100644 index 0000000000..f07dae4542 --- /dev/null +++ b/.changeset/postgres-hook-min-retention.md @@ -0,0 +1,5 @@ +--- +'@workflow/world-postgres': minor +--- + +Keep Hook tokens reserved through their configured minimum retention. diff --git a/packages/core/e2e/e2e.test.ts b/packages/core/e2e/e2e.test.ts index 144878c920..2939f25e3b 100644 --- a/packages/core/e2e/e2e.test.ts +++ b/packages/core/e2e/e2e.test.ts @@ -2049,10 +2049,7 @@ describe('e2e', () => { } ); - test.skipIf( - !isLocalDeployment() || - process.env.WORKFLOW_TARGET_WORLD === '@workflow/world-postgres' - )( + test.skipIf(!isLocalDeployment())( 'hookMinRetentionWorkflow - terminal Hook cannot resume and its token stays unavailable', { timeout: 60_000 }, async () => { diff --git a/packages/world-postgres/src/drizzle/migrations/0018_add_hook_token_retention.sql b/packages/world-postgres/src/drizzle/migrations/0018_add_hook_token_retention.sql new file mode 100644 index 0000000000..1d74383d93 --- /dev/null +++ b/packages/world-postgres/src/drizzle/migrations/0018_add_hook_token_retention.sql @@ -0,0 +1 @@ +ALTER TABLE "workflow"."workflow_hooks" ADD COLUMN "token_retention_until" timestamp with time zone; diff --git a/packages/world-postgres/src/drizzle/migrations/meta/_journal.json b/packages/world-postgres/src/drizzle/migrations/meta/_journal.json index b7fb5d8215..4ce969faa2 100644 --- a/packages/world-postgres/src/drizzle/migrations/meta/_journal.json +++ b/packages/world-postgres/src/drizzle/migrations/meta/_journal.json @@ -127,6 +127,13 @@ "when": 1785283200000, "tag": "0017_add_hook_resume_context", "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1785619990000, + "tag": "0018_add_hook_token_retention", + "breakpoints": true } ] } diff --git a/packages/world-postgres/src/drizzle/schema.ts b/packages/world-postgres/src/drizzle/schema.ts index 6ffb21abcb..1c901a4402 100644 --- a/packages/world-postgres/src/drizzle/schema.ts +++ b/packages/world-postgres/src/drizzle/schema.ts @@ -216,6 +216,9 @@ export const hooks = schema.table( projectId: varchar('project_id').notNull(), environment: varchar('environment').notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), + tokenRetentionUntil: timestamp('token_retention_until', { + withTimezone: true, + }), /** @deprecated */ metadataJson: jsonb('metadata').$type(), metadata: Cbor()('metadata_cbor'), @@ -225,7 +228,9 @@ export const hooks = schema.table( // Server-synthesized resume slice. Not carried by the hook_created event, // so this backend leaves it null; reads fall back to runs.get. resumeContext: Cbor>()('resume_context'), - } satisfies DrizzlishOfType>, + } satisfies DrizzlishOfType< + Cborized & { tokenRetentionUntil?: Date } + >, (tb) => [index().on(tb.runId), index().on(tb.token)] ); diff --git a/packages/world-postgres/src/index.ts b/packages/world-postgres/src/index.ts index 430cea0812..463ba42fba 100644 --- a/packages/world-postgres/src/index.ts +++ b/packages/world-postgres/src/index.ts @@ -64,6 +64,7 @@ export function createWorld( return { specVersion: SPEC_VERSION_CURRENT, + capabilities: { hookRetention: { active: true } }, ...storage, ...streamer, ...queue, diff --git a/packages/world-postgres/src/storage.ts b/packages/world-postgres/src/storage.ts index 140597a6a9..a1b64cde90 100644 --- a/packages/world-postgres/src/storage.ts +++ b/packages/world-postgres/src/storage.ts @@ -53,10 +53,15 @@ import { asc, desc, eq, + exists, gt, inArray, + isNull, lt, + lte, + notExists, notInArray, + or, sql, } from 'drizzle-orm'; import { monotonicFactory } from 'ulid'; @@ -403,6 +408,21 @@ async function handleLegacyEventPostgres( export function createEventsStorage(drizzle: Drizzle): Storage['events'] { const ulid = monotonicFactory(); const { events } = Schema; + const terminalRunStatuses: (typeof Schema.runs.status.enumValues)[number][] = + [...TERMINAL_WORKFLOW_RUN_STATUSES]; + const ownerRunIsTerminal = drizzle + .select({ runId: Schema.runs.runId }) + .from(Schema.runs) + .where( + and( + eq(Schema.runs.runId, Schema.hooks.runId), + inArray(Schema.runs.status, terminalRunStatuses) + ) + ); + const hookRetentionEnded = or( + isNull(Schema.hooks.tokenRetentionUntil), + lte(Schema.hooks.tokenRetentionUntil, sql`now()`) + ); // Prepared statements for validation queries (performance optimization) const getRunForValidation = drizzle @@ -434,7 +454,15 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { const getHookByToken = drizzle .select({ hookId: Schema.hooks.hookId, runId: Schema.hooks.runId }) .from(Schema.hooks) - .where(eq(Schema.hooks.token, sql.placeholder('token'))) + .where( + and( + eq(Schema.hooks.token, sql.placeholder('token')), + or( + gt(Schema.hooks.tokenRetentionUntil, sql`now()`), + notExists(ownerRunIsTerminal) + ) + ) + ) .limit(1) .prepare('events_get_hook_by_token'); @@ -902,13 +930,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { } } - // Terminal run statuses for use in SQL WHERE clauses (atomic guard). - // Must match the Vercel world's conditional expressions: - // ne(status, 'completed') AND ne(status, 'failed') AND ne(status, 'cancelled') - const terminalRunStatuses: (typeof Schema.runs.status.enumValues)[number][] = - [...TERMINAL_WORKFLOW_RUN_STATUSES]; - - // Handle run_completed event: update run status and cleanup hooks + // Handle run_completed event: update run status // Uses conditional UPDATE to prevent completing an already-terminal run. if (data.eventType === 'run_completed') { const eventData = (data as any).eventData as { output?: any }; @@ -942,18 +964,9 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { ); } } - // Delete all hooks and waits for this run to allow token reuse - await Promise.all([ - drizzle - .delete(Schema.hooks) - .where(eq(Schema.hooks.runId, effectiveRunId)), - drizzle - .delete(Schema.waits) - .where(eq(Schema.waits.runId, effectiveRunId)), - ]); } - // Handle run_failed event: update run status and cleanup hooks + // Handle run_failed event: update run status // Uses conditional UPDATE to prevent failing an already-terminal run. if (data.eventType === 'run_failed') { const eventData = (data as any).eventData as { @@ -994,18 +1007,9 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { ); } } - // Delete all hooks and waits for this run to allow token reuse - await Promise.all([ - drizzle - .delete(Schema.hooks) - .where(eq(Schema.hooks.runId, effectiveRunId)), - drizzle - .delete(Schema.waits) - .where(eq(Schema.waits.runId, effectiveRunId)), - ]); } - // Handle run_cancelled event: update run status and cleanup hooks + // Handle run_cancelled event: update run status // Uses conditional UPDATE to prevent cancelling an already-terminal run. // Note: idempotent run_cancelled on already-cancelled runs is handled // earlier in the pre-validation block (creates event and returns early). @@ -1039,11 +1043,17 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { ); } } - // Delete all hooks and waits for this run to allow token reuse + } + + if (isTerminalRunEventType(data.eventType)) { + // Retained Hooks remain visible after the run ends. Other Hooks and + // all waits are removed immediately. await Promise.all([ drizzle .delete(Schema.hooks) - .where(eq(Schema.hooks.runId, effectiveRunId)), + .where( + and(eq(Schema.hooks.runId, effectiveRunId), hookRetentionEnded) + ), drizzle .delete(Schema.waits) .where(eq(Schema.waits.runId, effectiveRunId)), @@ -1462,12 +1472,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { // Handle hook_created event: create hook entity // Uses prepared statement for token uniqueness check (performance optimization) if (data.eventType === 'hook_created') { - const eventData = (data as any).eventData as { - token: string; - metadata?: any; - isWebhook?: boolean; - isSystem?: boolean; - }; + const { eventData } = data; // Check for duplicate token using prepared statement const [existingHook] = await getHookByToken.execute({ @@ -1569,6 +1574,16 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { }; } } else { + await drizzle + .delete(Schema.hooks) + .where( + and( + eq(Schema.hooks.token, eventData.token), + exists(ownerRunIsTerminal), + hookRetentionEnded + ) + ); + const [hookValue] = await drizzle .insert(Schema.hooks) .values({ @@ -1579,6 +1594,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { ownerId: '', // TODO: get from context projectId: '', // TODO: get from context environment: '', // TODO: get from context + tokenRetentionUntil: eventData.tokenRetentionUntil, // Propagate specVersion from the event to the hook entity specVersion: effectiveSpecVersion, isWebhook: eventData.isWebhook, @@ -1941,11 +1957,27 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { } export function createHooksStorage(drizzle: Drizzle): Storage['hooks'] { - const { hooks } = Schema; + const { hooks, runs } = Schema; + const terminalRunStatuses: (typeof runs.status.enumValues)[number][] = [ + ...TERMINAL_WORKFLOW_RUN_STATUSES, + ]; + const ownerRunIsTerminal = drizzle + .select({ runId: runs.runId }) + .from(runs) + .where( + and( + eq(runs.runId, hooks.runId), + inArray(runs.status, terminalRunStatuses) + ) + ); + const available = or( + gt(hooks.tokenRetentionUntil, sql`now()`), + notExists(ownerRunIsTerminal) + ); const getByToken = drizzle .select() .from(hooks) - .where(eq(hooks.token, sql.placeholder('token'))) + .where(and(eq(hooks.token, sql.placeholder('token')), available)) .limit(1) .prepare('workflow_hooks_get_by_token'); @@ -1954,8 +1986,11 @@ export function createHooksStorage(drizzle: Drizzle): Storage['hooks'] { const [value] = await drizzle .select() .from(hooks) - .where(eq(hooks.hookId, hookId)) + .where(and(eq(hooks.hookId, hookId), available)) .limit(1); + if (!value) { + throw new HookNotFoundError(hookId); + } value.metadata ||= value.metadataJson; const parsed = HookSchema.parse(compact(value)); parsed.isWebhook ??= true; @@ -1984,6 +2019,7 @@ export function createHooksStorage(drizzle: Drizzle): Storage['hooks'] { .from(hooks) .where( and( + available, map(params.runId, (id) => eq(hooks.runId, id)), map(fromCursor, (c) => cursorFn(hooks.hookId, c)) ) diff --git a/packages/world-postgres/test/storage.test.ts b/packages/world-postgres/test/storage.test.ts index 1e4b422406..473e1017ea 100644 --- a/packages/world-postgres/test/storage.test.ts +++ b/packages/world-postgres/test/storage.test.ts @@ -1,6 +1,11 @@ import { execSync } from 'node:child_process'; import { PostgreSqlContainer } from '@testcontainers/postgresql'; -import type { Hook, Step, WorkflowRun } from '@workflow/world'; +import type { + Hook, + HookCreatedEventRequest, + Step, + WorkflowRun, +} from '@workflow/world'; import { SPEC_VERSION_CURRENT } from '@workflow/world'; import { encode } from 'cbor-x'; import { eq } from 'drizzle-orm'; @@ -106,16 +111,13 @@ async function updateStep( async function createHook( events: EventsStorage, runId: string, - data: { - hookId: string; - token: string; - metadata?: unknown; - } + data: HookCreatedEventRequest['eventData'] & { hookId: string } ): Promise { + const { hookId, ...eventData } = data; const result = await events.create(runId, { eventType: 'hook_created', - correlationId: data.hookId, - eventData: { token: data.token, metadata: data.metadata }, + correlationId: hookId, + eventData, }); if (!result.hook) { throw new Error('Expected hook to be created'); @@ -2503,6 +2505,94 @@ describe('Storage (Postgres integration)', () => { }) ).rejects.toThrow(/not found/i); }); + + it('should retain a hook until its deadline after the run completes', async () => { + const token = 'retained-token'; + const run = await createRun(events, { + deploymentId: 'deployment-123', + workflowName: 'test-workflow', + input: new Uint8Array(), + }); + const hook = await createHook(events, run.runId, { + hookId: 'hook_retained', + token, + tokenRetentionUntil: new Date(Date.now() + 60_000), + }); + + await updateRun(events, run.runId, 'run_completed', { + output: new Uint8Array(), + }); + + expect((await hooks.get(hook.hookId)).runId).toBe(run.runId); + expect((await hooks.getByToken(token)).runId).toBe(run.runId); + expect((await hooks.list({ runId: run.runId })).data).toHaveLength(1); + + const duplicateRun = await createRun(events, { + deploymentId: 'deployment-456', + workflowName: 'duplicate-workflow', + input: new Uint8Array(), + }); + const conflict = await events.create(duplicateRun.runId, { + eventType: 'hook_created', + correlationId: 'hook_duplicate', + eventData: { token }, + }); + expect(conflict.event.eventType).toBe('hook_conflict'); + + await events.create(run.runId, { + eventType: 'hook_disposed', + correlationId: hook.hookId, + }); + expect( + ( + await createHook(events, duplicateRun.runId, { + hookId: 'hook_replacement', + token, + }) + ).runId + ).toBe(duplicateRun.runId); + }); + + it('should release a retained hook after its deadline', async () => { + const token = 'elapsed-retention-token'; + const run = await createRun(events, { + deploymentId: 'deployment-123', + workflowName: 'test-workflow', + input: new Uint8Array(), + }); + const hook = await createHook(events, run.runId, { + hookId: 'hook_elapsed_retention', + token, + tokenRetentionUntil: new Date(Date.now() + 60_000), + }); + + await updateRun(events, run.runId, 'run_completed', { + output: new Uint8Array(), + }); + await drizzle + .update(DrizzleSchema.hooks) + .set({ tokenRetentionUntil: new Date(Date.now() - 1) }) + .where(eq(DrizzleSchema.hooks.hookId, hook.hookId)); + await expect(hooks.getByToken(token)).rejects.toMatchObject({ + name: 'HookNotFoundError', + }); + + const replacementRun = await createRun(events, { + deploymentId: 'deployment-456', + workflowName: 'replacement-workflow', + input: new Uint8Array(), + }); + await createHook(events, replacementRun.runId, { + hookId: 'hook_after_retention', + token, + }); + + const rows = await drizzle + .select({ hookId: DrizzleSchema.hooks.hookId }) + .from(DrizzleSchema.hooks) + .where(eq(DrizzleSchema.hooks.token, token)); + expect(rows).toEqual([{ hookId: 'hook_after_retention' }]); + }); }); describe('disallowed operations on terminal runs', () => { From 281ac394f4c8a3c4bb76f4687e6e2a73d98af903 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:37:10 -0700 Subject: [PATCH 02/10] refactor(world-postgres): reuse terminal run statuses --- packages/world-postgres/src/storage.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/world-postgres/src/storage.ts b/packages/world-postgres/src/storage.ts index a1b64cde90..b89d4c1b0c 100644 --- a/packages/world-postgres/src/storage.ts +++ b/packages/world-postgres/src/storage.ts @@ -408,15 +408,13 @@ async function handleLegacyEventPostgres( export function createEventsStorage(drizzle: Drizzle): Storage['events'] { const ulid = monotonicFactory(); const { events } = Schema; - const terminalRunStatuses: (typeof Schema.runs.status.enumValues)[number][] = - [...TERMINAL_WORKFLOW_RUN_STATUSES]; const ownerRunIsTerminal = drizzle .select({ runId: Schema.runs.runId }) .from(Schema.runs) .where( and( eq(Schema.runs.runId, Schema.hooks.runId), - inArray(Schema.runs.status, terminalRunStatuses) + inArray(Schema.runs.status, TERMINAL_WORKFLOW_RUN_STATUSES) ) ); const hookRetentionEnded = or( @@ -945,7 +943,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { .where( and( eq(Schema.runs.runId, effectiveRunId), - notInArray(Schema.runs.status, terminalRunStatuses) + notInArray(Schema.runs.status, TERMINAL_WORKFLOW_RUN_STATUSES) ) ) .returning(); @@ -988,7 +986,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { .where( and( eq(Schema.runs.runId, effectiveRunId), - notInArray(Schema.runs.status, terminalRunStatuses) + notInArray(Schema.runs.status, TERMINAL_WORKFLOW_RUN_STATUSES) ) ) .returning(); @@ -1024,7 +1022,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { .where( and( eq(Schema.runs.runId, effectiveRunId), - notInArray(Schema.runs.status, terminalRunStatuses) + notInArray(Schema.runs.status, TERMINAL_WORKFLOW_RUN_STATUSES) ) ) .returning(); @@ -1958,16 +1956,13 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { export function createHooksStorage(drizzle: Drizzle): Storage['hooks'] { const { hooks, runs } = Schema; - const terminalRunStatuses: (typeof runs.status.enumValues)[number][] = [ - ...TERMINAL_WORKFLOW_RUN_STATUSES, - ]; const ownerRunIsTerminal = drizzle .select({ runId: runs.runId }) .from(runs) .where( and( eq(runs.runId, hooks.runId), - inArray(runs.status, terminalRunStatuses) + inArray(runs.status, TERMINAL_WORKFLOW_RUN_STATUSES) ) ); const available = or( From 3bbdfb53d985264849f55626cdcf27b4f3c63470 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:38:12 -0700 Subject: [PATCH 03/10] docs: note Postgres Hook retention support --- docs/content/docs/v5/api-reference/workflow/create-hook.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/docs/v5/api-reference/workflow/create-hook.mdx b/docs/content/docs/v5/api-reference/workflow/create-hook.mdx index 9b01d71dc3..23aca62bb4 100644 --- a/docs/content/docs/v5/api-reference/workflow/create-hook.mdx +++ b/docs/content/docs/v5/api-reference/workflow/create-hook.mdx @@ -187,7 +187,7 @@ After the workflow ends, [`getHookByToken()`](/docs/api-reference/workflow-api/g -This option is experimental. The Local World supports it. If the configured World does not support it, the workflow fails when registering the Hook. `createWebhook()` does not accept this option. +This option is experimental. The Local and Postgres Worlds support it. If the configured World does not support it, the workflow fails when registering the Hook. `createWebhook()` does not accept this option. ### Waiting for Multiple Payloads From ad5832173dbd14fd626cee7b3e88e1deb9d858c2 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:57:13 -0700 Subject: [PATCH 04/10] fix(world-postgres): expose hook retention deadline --- packages/world-postgres/README.md | 7 +++++++ packages/world-postgres/src/drizzle/schema.ts | 4 +--- packages/world-postgres/test/storage.test.ts | 19 +++++++++++++++++-- packages/world/src/hooks.test.ts | 8 +++++++- packages/world/src/hooks.ts | 3 +++ 5 files changed, 35 insertions(+), 6 deletions(-) diff --git a/packages/world-postgres/README.md b/packages/world-postgres/README.md index ed4ed147c3..f45f947349 100644 --- a/packages/world-postgres/README.md +++ b/packages/world-postgres/README.md @@ -162,6 +162,13 @@ import * as schema from '@workflow/world-postgres/schema'; Make sure your PostgreSQL database is accessible and the user has sufficient permissions to create tables and manage jobs. +### Data Retention + +Postgres World does not yet perform general workflow-run cleanup. After a +retained Hook's run ends and its deadline passes, reads treat the Hook as absent +and its token can be reused. If the token is never reused, the expired +`workflow_hooks` row remains until broader workflow-run cleanup removes it. + ## Features - **Durable Storage**: Stores workflow runs, events, steps, hooks, and webhooks in PostgreSQL diff --git a/packages/world-postgres/src/drizzle/schema.ts b/packages/world-postgres/src/drizzle/schema.ts index 90e1631b30..578c78cf2d 100644 --- a/packages/world-postgres/src/drizzle/schema.ts +++ b/packages/world-postgres/src/drizzle/schema.ts @@ -238,9 +238,7 @@ export const hooks = schema.table( // `resumeCapabilities` is deliberately response-only — attested fresh on // each by-token lookup, never persisted — so it must not become a column. } satisfies DrizzlishOfType< - Cborized, 'metadata'> & { - tokenRetentionUntil?: Date; - } + Cborized, 'metadata'> >, (tb) => [index().on(tb.runId), index().on(tb.token)] ); diff --git a/packages/world-postgres/test/storage.test.ts b/packages/world-postgres/test/storage.test.ts index 473e1017ea..3c26be6bce 100644 --- a/packages/world-postgres/test/storage.test.ts +++ b/packages/world-postgres/test/storage.test.ts @@ -2508,6 +2508,7 @@ describe('Storage (Postgres integration)', () => { it('should retain a hook until its deadline after the run completes', async () => { const token = 'retained-token'; + const tokenRetentionUntil = new Date(Date.now() + 60_000); const run = await createRun(events, { deploymentId: 'deployment-123', workflowName: 'test-workflow', @@ -2516,16 +2517,27 @@ describe('Storage (Postgres integration)', () => { const hook = await createHook(events, run.runId, { hookId: 'hook_retained', token, - tokenRetentionUntil: new Date(Date.now() + 60_000), + tokenRetentionUntil, }); + expect(hook.tokenRetentionUntil).toEqual(tokenRetentionUntil); await updateRun(events, run.runId, 'run_completed', { output: new Uint8Array(), }); expect((await hooks.get(hook.hookId)).runId).toBe(run.runId); - expect((await hooks.getByToken(token)).runId).toBe(run.runId); + expect(await hooks.getByToken(token)).toMatchObject({ + runId: run.runId, + tokenRetentionUntil, + }); expect((await hooks.list({ runId: run.runId })).data).toHaveLength(1); + await expect( + events.create(run.runId, { + eventType: 'hook_received', + correlationId: hook.hookId, + eventData: { payload: {} }, + }) + ).rejects.toMatchObject({ name: 'RunExpiredError' }); const duplicateRun = await createRun(events, { deploymentId: 'deployment-456', @@ -2573,6 +2585,9 @@ describe('Storage (Postgres integration)', () => { .update(DrizzleSchema.hooks) .set({ tokenRetentionUntil: new Date(Date.now() - 1) }) .where(eq(DrizzleSchema.hooks.hookId, hook.hookId)); + await expect(hooks.get(hook.hookId)).rejects.toMatchObject({ + name: 'HookNotFoundError', + }); await expect(hooks.getByToken(token)).rejects.toMatchObject({ name: 'HookNotFoundError', }); diff --git a/packages/world/src/hooks.test.ts b/packages/world/src/hooks.test.ts index 62ef17c071..bf0aa40e61 100644 --- a/packages/world/src/hooks.test.ts +++ b/packages/world/src/hooks.test.ts @@ -20,7 +20,13 @@ const resumeContext = { encryptionPublicKey: 'ZmFrZS1wdWJsaWMta2V5', }; -describe('HookSchema resumeContext', () => { +describe('HookSchema', () => { + it('coerces tokenRetentionUntil to a Date', () => { + const tokenRetentionUntil = '2026-08-01T00:00:00.000Z'; + const parsed = HookSchema.parse({ ...baseHook, tokenRetentionUntil }); + expect(parsed.tokenRetentionUntil).toEqual(new Date(tokenRetentionUntil)); + }); + it('parses and preserves a resumeContext', () => { const parsed = HookSchema.parse({ ...baseHook, resumeContext }); expect(parsed.resumeContext).toEqual(resumeContext); diff --git a/packages/world/src/hooks.ts b/packages/world/src/hooks.ts index 3a74e06c38..6b3d74bc99 100644 --- a/packages/world/src/hooks.ts +++ b/packages/world/src/hooks.ts @@ -101,6 +101,9 @@ export const HookSchema = z.object({ environment: z.string(), metadata: SerializedDataSchema.optional(), createdAt: z.coerce.date(), + // Earliest time the token can become available after the owning run ends. + // An active run keeps the token beyond this deadline. + tokenRetentionUntil: z.coerce.date().optional(), // Optional in database for backwards compatibility, defaults to 1 (legacy) when reading specVersion: z.number().optional(), isWebhook: z.boolean().optional(), From 46d3b2ffe04497f567a63d0a6e42830f139525fe Mon Sep 17 00:00:00 2001 From: "vercel[bot]" <35613825+vercel[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:35:28 +0000 Subject: [PATCH 05/10] Fix: Exhaustive `Record` in `attribute-panel.tsx` is missing the `tokenRetentionUntil` key that was added to `HookSchema`, causing TS2741 and breaking every Vercel build. This commit fixes the issue reported at packages/web-shared/src/components/sidebar/attribute-panel.tsx:426 ## Bug Commit `ad58321` added `tokenRetentionUntil: z.coerce.date().optional()` to `HookSchema` in `packages/world/src/hooks.ts:106`. This adds `tokenRetentionUntil` to the inferred `Hook` type. In `packages/web-shared/src/components/sidebar/attribute-panel.tsx`, `AttributeKey` is a union that includes `keyof Hook`, so `tokenRetentionUntil` becomes a required member of the **exhaustive** `Record ...>` object literal `attributeToDisplayFn` (starting at line ~426). Because the literal had no `tokenRetentionUntil` entry, `tsc` fails: ``` src/components/sidebar/attribute-panel.tsx(426,7): error TS2741: Property 'tokenRetentionUntil' is missing in type '{ ... }' but required in type 'Record ReactNode>'. ``` This breaks `@workflow/web-shared#build` and therefore every Vercel deployment (17 failing deployments observed, all with this identical error). ## Fix Added a `tokenRetentionUntil` entry to `attributeToDisplayFn`, placed alongside the other Hook date fields (`lastReceivedAt`, `disposedAt`): ```ts tokenRetentionUntil: timestampWithTooltipOrNull, ``` `tokenRetentionUntil` is a `Date` field, and `timestampWithTooltipOrNull` (defined at line 402) is the display helper used by all the other surfaced date fields (`createdAt`, `startedAt`, `completedAt`, `retryAfter`, `resumeAt`, `occurredAt`). Given the intent of `ad58321` was to expose the hook retention deadline, surfacing it as a tooltip-annotated timestamp is the consistent choice. Only `attributeToDisplayFn` is a fully exhaustive `Record`; the other maps are `Partial<...>` / `Set`, so no other edits are required. ## Verification `node_modules` are not installed in this sandbox, so `tsc` could not be executed directly. Verified structurally instead: the newly added `tokenRetentionUntil` entry (line 449) references `timestampWithTooltipOrNull`, which is defined in-file at line 402 and already used by the sibling date entries, so the fix satisfies the missing-key requirement without introducing new type errors. Co-authored-by: Vercel Co-authored-by: VaguelySerious --- packages/web-shared/src/components/sidebar/attribute-panel.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/web-shared/src/components/sidebar/attribute-panel.tsx b/packages/web-shared/src/components/sidebar/attribute-panel.tsx index 23f963a531..d2d7b26d4e 100644 --- a/packages/web-shared/src/components/sidebar/attribute-panel.tsx +++ b/packages/web-shared/src/components/sidebar/attribute-panel.tsx @@ -446,6 +446,7 @@ const attributeToDisplayFn: Record< receivedCount: (value: unknown) => String(value), lastReceivedAt: localMillisecondTimeOrNull, disposedAt: localMillisecondTimeOrNull, + tokenRetentionUntil: timestampWithTooltipOrNull, // Internal resume plumbing — not surfaced in the UI resumeContext: (_value: unknown) => null, resumeId: (_value: unknown) => null, From 99db94fe7e355b47bfe337a33c7e42f3ecf26cce Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:02:52 -0700 Subject: [PATCH 06/10] docs(world-postgres): clarify expired hook rows --- packages/world-postgres/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/world-postgres/README.md b/packages/world-postgres/README.md index f45f947349..3aaf685db3 100644 --- a/packages/world-postgres/README.md +++ b/packages/world-postgres/README.md @@ -167,7 +167,7 @@ Make sure your PostgreSQL database is accessible and the user has sufficient per Postgres World does not yet perform general workflow-run cleanup. After a retained Hook's run ends and its deadline passes, reads treat the Hook as absent and its token can be reused. If the token is never reused, the expired -`workflow_hooks` row remains until broader workflow-run cleanup removes it. +`workflow_hooks` row remains. ## Features From 38d92e4be25774315dffd2dc6a984a953c785c30 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:20:39 -0700 Subject: [PATCH 07/10] feat(world-postgres): enforce Hook retention limit --- docs/content/docs/v5/configuration/worlds.mdx | 7 +++ docs/content/worlds/v5/postgres.mdx | 4 ++ packages/world-postgres/README.md | 4 ++ packages/world-postgres/src/drizzle/schema.ts | 9 +-- packages/world-postgres/src/storage.ts | 28 +++++++++ packages/world-postgres/test/storage.test.ts | 58 +++++++++++++++++++ 6 files changed, 104 insertions(+), 6 deletions(-) diff --git a/docs/content/docs/v5/configuration/worlds.mdx b/docs/content/docs/v5/configuration/worlds.mdx index 58047cfbc7..be63f77ded 100644 --- a/docs/content/docs/v5/configuration/worlds.mdx +++ b/docs/content/docs/v5/configuration/worlds.mdx @@ -167,6 +167,13 @@ The Postgres World is a self-hosted durable backend for long-running server proc - Default: `pg` default - Maximum size of the internal `pg.Pool` when the World creates the pool. +### `WORKFLOW_POSTGRES_HOOK_RETENTION_LIMIT_DAYS` + +- Factory option: none +- Default: `30` +- Maximum [`experimental_minRetention`](/docs/api-reference/workflow/create-hook#keep-a-token-unavailable-after-the-run-ends) accepted by the Postgres World, in days. +- Set this to the same limit as your production World so oversized values fail during development. + ### `namespace` - Environment variable fallback: `WORKFLOW_QUEUE_NAMESPACE` diff --git a/docs/content/worlds/v5/postgres.mdx b/docs/content/worlds/v5/postgres.mdx index 55ea6dc741..a9d3c5172e 100644 --- a/docs/content/worlds/v5/postgres.mdx +++ b/docs/content/worlds/v5/postgres.mdx @@ -218,6 +218,10 @@ For higher worker concurrency, Graphile Worker recommends setting `maxPoolSize` Set to `1` when the application or framework coordinates shutdown and awaits `world.close()` before closing its workflow HTTP server and any caller-owned pool. Default: unset (`false`). +### `WORKFLOW_POSTGRES_HOOK_RETENTION_LIMIT_DAYS` + +Maximum [`experimental_minRetention`](/docs/api-reference/workflow/create-hook#keep-a-token-unavailable-after-the-run-ends) accepted by the Postgres World, in days. Default: `30`. Set this to the same limit as your production World so oversized values fail during development. + ### `WORKFLOW_QUEUE_NAMESPACE` Queue topic namespace shared by build output and the Postgres World. Default: unset. diff --git a/packages/world-postgres/README.md b/packages/world-postgres/README.md index 3aaf685db3..cf37651eaa 100644 --- a/packages/world-postgres/README.md +++ b/packages/world-postgres/README.md @@ -41,6 +41,9 @@ export WORKFLOW_POSTGRES_MAX_POOL_SIZE="10" # Optional: Let the application coordinate shutdown (default: false) export WORKFLOW_POSTGRES_APPLICATION_MANAGED_SHUTDOWN="1" + +# Optional: Maximum Hook minimum retention in days (default: 30) +export WORKFLOW_POSTGRES_HOOK_RETENTION_LIMIT_DAYS="30" ``` ### Programmatic Usage @@ -106,6 +109,7 @@ An aborted HTTP request does not guarantee that its server-side handler stopped, | `WORKFLOW_POSTGRES_WORKER_CONCURRENCY` | Number of concurrent workers | `50` | | `WORKFLOW_POSTGRES_MAX_POOL_SIZE` | Internal `pg.Pool` max size | `10` | | `WORKFLOW_POSTGRES_APPLICATION_MANAGED_SHUTDOWN` | Set to `1` when the application coordinates shutdown and awaits `world.close()` | unset (`false`) | +| `WORKFLOW_POSTGRES_HOOK_RETENTION_LIMIT_DAYS` | Maximum Hook minimum retention in days | `30` | When `pool` is omitted, `maxPoolSize` precedence is: `createWorld({ maxPoolSize })`, then `WORKFLOW_POSTGRES_MAX_POOL_SIZE`, then the `pg.Pool` default. diff --git a/packages/world-postgres/src/drizzle/schema.ts b/packages/world-postgres/src/drizzle/schema.ts index 851356fefd..578c78cf2d 100644 --- a/packages/world-postgres/src/drizzle/schema.ts +++ b/packages/world-postgres/src/drizzle/schema.ts @@ -235,13 +235,10 @@ export const hooks = schema.table( // Server-synthesized resume slice. Not carried by the hook_created event, // so this backend leaves it null; reads fall back to runs.get. resumeContext: Cbor>()('resume_context'), - // `resumeCapabilities` is response-only. Postgres Hook retention is not - // implemented yet, so neither field has a column. + // `resumeCapabilities` is deliberately response-only — attested fresh on + // each by-token lookup, never persisted — so it must not become a column. } satisfies DrizzlishOfType< - Cborized< - Omit, - 'metadata' - > + Cborized, 'metadata'> >, (tb) => [index().on(tb.runId), index().on(tb.token)] ); diff --git a/packages/world-postgres/src/storage.ts b/packages/world-postgres/src/storage.ts index b89d4c1b0c..938ef77c3b 100644 --- a/packages/world-postgres/src/storage.ts +++ b/packages/world-postgres/src/storage.ts @@ -69,6 +69,21 @@ import { type Drizzle, Schema } from './drizzle/index.js'; import type { SerializedContent } from './drizzle/schema.js'; import { compact } from './util.js'; +const DAY_MS = 24 * 60 * 60 * 1000; + +function getHookRetentionLimitMs(): number { + const days = Number( + process.env.WORKFLOW_POSTGRES_HOOK_RETENTION_LIMIT_DAYS ?? 30 + ); + if (!Number.isFinite(days) || days <= 0) { + throw new WorkflowWorldError( + 'WORKFLOW_POSTGRES_HOOK_RETENTION_LIMIT_DAYS must be a positive number', + { status: 400 } + ); + } + return days * DAY_MS; +} + /** * Read helper for the deprecated `error` text column (legacy: JSON-stringified * `StructuredError`). In the current event-sourced model, the `error` field on @@ -406,6 +421,7 @@ async function handleLegacyEventPostgres( } export function createEventsStorage(drizzle: Drizzle): Storage['events'] { + const hookRetentionLimitMs = getHookRetentionLimitMs(); const ulid = monotonicFactory(); const { events } = Schema; const ownerRunIsTerminal = drizzle @@ -492,6 +508,18 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { return { async create(runId, data, params): Promise { + if ( + data.eventType === 'hook_created' && + data.eventData.tokenRetentionUntil !== undefined && + data.eventData.tokenRetentionUntil.getTime() > + Date.now() + hookRetentionLimitMs + ) { + throw new WorkflowWorldError( + `Hook minimum retention cannot exceed ${hookRetentionLimitMs / DAY_MS} days in the Postgres World.`, + { status: 400 } + ); + } + let eventId: string | undefined; const getEventId = () => (eventId ??= `wevt_${ulid()}`); diff --git a/packages/world-postgres/test/storage.test.ts b/packages/world-postgres/test/storage.test.ts index 3c26be6bce..ff3a3ce8fe 100644 --- a/packages/world-postgres/test/storage.test.ts +++ b/packages/world-postgres/test/storage.test.ts @@ -13,6 +13,7 @@ import { Pool } from 'pg'; import { decodeTime, ulid } from 'ulid'; import { afterAll, + afterEach, beforeAll, beforeEach, describe, @@ -172,6 +173,10 @@ describe('Storage (Postgres integration)', () => { await truncateTables(); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + afterAll(async () => { await pool.end(); await container.stop(); @@ -2565,6 +2570,59 @@ describe('Storage (Postgres integration)', () => { ).toBe(duplicateRun.runId); }); + it('rejects retention beyond the 30-day default before writing Hook state', async () => { + const run = await createRun(events, { + deploymentId: 'deployment-123', + workflowName: 'test-workflow', + input: new Uint8Array(), + }); + const hookId = 'hook_over_retention_limit'; + + await expect( + createHook(events, run.runId, { + hookId, + token: 'over-retention-limit', + tokenRetentionUntil: new Date(Date.now() + 31 * 24 * 60 * 60 * 1000), + }) + ).rejects.toMatchObject({ + name: 'WorkflowWorldError', + status: 400, + message: + 'Hook minimum retention cannot exceed 30 days in the Postgres World.', + }); + + await expect( + drizzle + .select() + .from(DrizzleSchema.hooks) + .where(eq(DrizzleSchema.hooks.hookId, hookId)) + ).resolves.toEqual([]); + await expect( + drizzle + .select() + .from(DrizzleSchema.events) + .where(eq(DrizzleSchema.events.correlationId, hookId)) + ).resolves.toEqual([]); + }); + + it('accepts retention within the configured Postgres limit', async () => { + vi.stubEnv('WORKFLOW_POSTGRES_HOOK_RETENTION_LIMIT_DAYS', '60'); + const configuredEvents = createEventsStorage(drizzle); + const run = await createRun(configuredEvents, { + deploymentId: 'deployment-123', + workflowName: 'test-workflow', + input: new Uint8Array(), + }); + + await expect( + createHook(configuredEvents, run.runId, { + hookId: 'hook_custom_retention_limit', + token: 'custom-retention-limit', + tokenRetentionUntil: new Date(Date.now() + 31 * 24 * 60 * 60 * 1000), + }) + ).resolves.toMatchObject({ hookId: 'hook_custom_retention_limit' }); + }); + it('should release a retained hook after its deadline', async () => { const token = 'elapsed-retention-token'; const run = await createRun(events, { From e6d9b6169c23e83989e21cb0dc663ab664dc4a71 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:28:16 -0700 Subject: [PATCH 08/10] fix(world): remove duplicate Hook retention field --- packages/world/src/hooks.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/world/src/hooks.ts b/packages/world/src/hooks.ts index 6dfe9c5fc1..2ca7481a0c 100644 --- a/packages/world/src/hooks.ts +++ b/packages/world/src/hooks.ts @@ -101,9 +101,6 @@ export const HookSchema = z.object({ environment: z.string(), metadata: SerializedDataSchema.optional(), createdAt: z.coerce.date(), - // Earliest time the token can become available after the owning run ends. - // An active run keeps the token beyond this deadline. - tokenRetentionUntil: z.coerce.date().optional(), // Optional in database for backwards compatibility, defaults to 1 (legacy) when reading specVersion: z.number().optional(), isWebhook: z.boolean().optional(), From b0cd01fcf1c4c911ef4d7c5d72c89f6298fc3cab Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:29:23 -0700 Subject: [PATCH 09/10] fix(web-shared): remove duplicate retention renderer --- packages/web-shared/src/components/sidebar/attribute-panel.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/web-shared/src/components/sidebar/attribute-panel.tsx b/packages/web-shared/src/components/sidebar/attribute-panel.tsx index 4158645611..7e9e4afd0a 100644 --- a/packages/web-shared/src/components/sidebar/attribute-panel.tsx +++ b/packages/web-shared/src/components/sidebar/attribute-panel.tsx @@ -449,7 +449,6 @@ const attributeToDisplayFn: Record< receivedCount: (value: unknown) => String(value), lastReceivedAt: localMillisecondTimeOrNull, disposedAt: localMillisecondTimeOrNull, - tokenRetentionUntil: timestampWithTooltipOrNull, // Internal resume plumbing — not surfaced in the UI resumeContext: (_value: unknown) => null, resumeId: (_value: unknown) => null, From 03ae58a5db44997fb9da3f04ff7fe34b106ac9f4 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:38:40 -0700 Subject: [PATCH 10/10] test(world): remove redundant retention coercion case --- packages/world/src/hooks.test.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/world/src/hooks.test.ts b/packages/world/src/hooks.test.ts index bf0aa40e61..62ef17c071 100644 --- a/packages/world/src/hooks.test.ts +++ b/packages/world/src/hooks.test.ts @@ -20,13 +20,7 @@ const resumeContext = { encryptionPublicKey: 'ZmFrZS1wdWJsaWMta2V5', }; -describe('HookSchema', () => { - it('coerces tokenRetentionUntil to a Date', () => { - const tokenRetentionUntil = '2026-08-01T00:00:00.000Z'; - const parsed = HookSchema.parse({ ...baseHook, tokenRetentionUntil }); - expect(parsed.tokenRetentionUntil).toEqual(new Date(tokenRetentionUntil)); - }); - +describe('HookSchema resumeContext', () => { it('parses and preserves a resumeContext', () => { const parsed = HookSchema.parse({ ...baseHook, resumeContext }); expect(parsed.resumeContext).toEqual(resumeContext);