Skip to content

[world-postgres] Scope startup recovery to the World's jobPrefix - #4761

Merged
VaguelySerious merged 12 commits into
mainfrom
pgp/world-postgres-start-scope
Oct 30, 2026
Merged

VaguelySerious merged 12 commits into
mainfrom
pgp/world-postgres-start-scope

Conversation

@pranaygp

@pranaygp pranaygp commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

world-postgres's start() re-enqueues every active run in the database into its own ${jobPrefix}flows Graphile task, and its own runner then drives them. jobPrefix is the documented knob for sharing a database, and it does keep live jobs apart, because a runner claims only its own task. Startup recovery ignores it, so two apps with distinct prefixes still take over each other's active runs on every boot.

This PR stamps each run with the prefix of the World that created it and scopes start()'s recovery to job_prefix = <own prefix> OR job_prefix IS NULL. Single-app installs see no change.

Refs #2978 (the recovery half only).

Why

  • Apps that share a Postgres database are told to set distinct jobPrefix values ("Useful when sharing a database between multiple applications"). With today's recovery, that only separates them until one of them restarts.
  • namespace (WORKFLOW_QUEUE_NAMESPACE) does not help either: world-postgres uses it only for the topic prefix, which it drops at enqueue.
  • When the apps' workflow sets differ, a foreign delivery should fail the run outright: WorkflowNotRegisteredError is classified RUNTIME_ERROR, then run_failed. This is inferred from the code; I did not reproduce it. When the sets overlap, the wrong app runs the workflow. In Shared Postgres world: queue namespaces don't isolate claiming, and startup recovery re-enqueues other applications' active runs #2978, agents ran each other's turns with the wrong credentials.
  • We hit this running a durable agent harness on world-postgres that uses the queue lease, plus startup recovery, as its only crash backstop. Its sessions are long-lived runs that stay active for the whole conversation, so every World started against the shared database re-enqueued all of them into its own task (72 in one setup, on every boot). That backstop only works if recovery hands a run to the runner that owns it.

The behaviour is unchanged on main since 5.0.2.

What changed

All in packages/world-postgres:

  • Schema: a nullable workflow_runs.job_prefix varchar column, migration 0028_add_run_job_prefix (ADD COLUMN IF NOT EXISTS, since two concurrent bootstraps can both apply it).
  • Stamping (storage.ts): createEventsStorage(drizzle, { jobPrefix }) stamps both run-creation paths: an explicit run_created, and the resilient run_started that recreates a run from its queued message.
  • Scoped recovery (storage.ts, index.ts): runs.list is extracted into createRunsList(drizzle, scope?), and a new createRecoverableRunsList(drizzle, jobPrefix) adds the job_prefix = $1 OR job_prefix IS NULL predicate. start() passes reenqueueActiveRuns a runs storage whose list is the scoped one.
  • One source for the prefix (config.ts, queue.ts): a new resolveJobPrefix(config) (config.jobPrefix || 'workflow_') feeds both the Graphile task names and the stamp, so a recovered run always lands in the task whose runner claims it.
  • Unchanged public surface: the public runs.list (CLI, dashboard) stays unscoped, and jobPrefix is stripped from every run storage returns. There is no change to the @workflow/world interface. The shape follows world-local's scoped recovery in [world-local] Scope untagged startup recovery to untagged runs #2667.
  • Docs: the Postgres World page (WORKFLOW_POSTGRES_JOB_PREFIX), Configuration › Worlds (jobPrefix) and the package README now say to give each app sharing a database its own prefix, set it through the environment, that the namespace does not separate apps, and give the SQL that assigns unstamped runs to one app or re-homes runs after a prefix rename.
  • Changeset: @workflow/world-postgres: minor.

Compatibility and rollout

  • Run bootstrap before deploying this version. Drizzle names every column in its selects and inserts, so new code on an un-migrated database fails with column "job_prefix" does not exist. Old code on a migrated database is fine, because the column is nullable. This is the same contract as the earlier column adds (0016, 0024), and the changeset says so.
  • Upgrade and rolling deploys strand nothing. Runs created by earlier versions (including old code still running after the migration) have no prefix, and every World keeps recovering them, exactly as today.
  • Single-app installs: no change. Every run is either unstamped or stamped with the app's own prefix.
  • Apps sharing a database and a prefix still cross, as before; they already cross-claim live jobs (Shared Postgres world: queue namespaces don't isolate claiming, and startup recovery re-enqueues other applications' active runs #2978's claim half, out of scope here).
  • Renaming a jobPrefix: runs stamped with the old prefix are no longer swept into the new prefix's task (their live jobs were already orphaned in the old task). The docs give a one-line UPDATE to re-home them.
  • Prefix set only in code: the CLI and the observability UI build their World from the environment, so a dashboard re-run is stamped with the prefix they see (workflow_ by default). Before this PR, the app's next boot swept such a run into its own task; now it stays in a task only a World with that prefix claims. The docs now say to set the prefix through WORKFLOW_POSTGRES_JOB_PREFIX.
  • Not addressed: within one prefix, recovery still enqueues a second delivery next to a run's pending job, so a recovered run can be delivered twice. That is fix(world-postgres): skip parked runs and dedupe recovery jobs on startup #3162's dedupe.

How it was tested

  • packages/world-postgres, unit (pnpm vitest run src): 8 files, 75 tests pass. src/reenqueue.test.ts covers recovery going through the scoped listing rather than the public runs.list, and the scope and stamp following the default prefix, a configured prefix, and WORKFLOW_POSTGRES_JOB_PREFIX through the default config. Against the old index.ts, 5 of its 16 cases fail.
  • New integration test test/recovery-scope.test.ts. An app_a_ World, an app_b_ World and prefix-less storage each create one active run. A's start() must enqueue exactly {A, legacy} into app_a_flows and B's exactly {B, legacy} into app_b_flows, while the public runs.list still returns all three. Both creation paths must stamp the effective prefix, and the returned run must not carry jobPrefix. On main this fails: A's start() enqueued 3 runs, B's included.
  • Integration suite: the earlier revision of this branch passed the full pnpm test (20 files, 327 tests, testcontainers postgres:15-alpine). After the renumber and the merge of main, all 12 test/ files (252 tests) pass against a local Postgres 14, with the testcontainers module pointed at that server. Together with the unit tests that is again 327/327.
  • Migrations: a fresh database applies all 29 migrations and gets a nullable job_prefix varchar. After merging main (with [world] In-band writer fence for world-local and world-postgres #4821's 0026 and fix(world-postgres): store timestamps as timestamp with time zone #4757's 0027): @workflow/world-postgres 394/394, including fix(world-postgres): store timestamps as timestamp with time zone #4757's journal-order test and its concurrent-bootstrap test (which failed on a plain ADD COLUMN before the IF NOT EXISTS); typecheck and changeset check clean.
  • pnpm typecheck, pnpm exec biome ci . (0 errors), node scripts/check-changesets.mjs, node scripts/check-no-unrun-tests.mjs, and docs bun ./scripts/lint.ts pass.

Open questions for reviewers

  1. Unstamped (legacy) runs are recovered by every World. The alternative is to treat NULL as the default prefix workflow_, which isolates legacy runs sooner but strands the open runs of any app that already uses a custom prefix until someone runs the documented UPDATE. I chose "nothing is stranded". OK?
  2. Stripping the stamp. jobPrefix is internal routing, so it is stripped from returned runs and kept out of @workflow/world. Would you rather expose it on the run, for example in the CLI, to make a misconfigured prefix easier to spot?
  3. Changeset bump. minor (a behaviour change plus a migration). Happy to make it patch.

Related PRs and merge order

Docs Preview

Page Preview
Postgres World, WORKFLOW_POSTGRES_JOB_PREFIX /worlds/postgres#workflow_postgres_job_prefix
Configuration › Worlds, jobPrefix /docs/configuration/worlds#jobprefix

PR Checklist - Required to merge

  • 📦 pnpm changeset was run to create a changelog for this PR
  • 🔒 DCO sign-off passes (run git commit --signoff on your commits)
  • 📝 Ping @vercel/workflow in a comment once the PR is ready, and the above checklist is complete

🤖 Generated with Claude Code

pranaygp and others added 2 commits October 7, 2026 22:52
`start()` re-enqueued every active run in the database into this World's
own `${jobPrefix}flows` task, whatever prefix the run was created under,
so the World's runner then drove it. Apps that share a database and keep
their live jobs apart with distinct `jobPrefix` values still took over
each other's active runs on every boot.

Each run is now stamped with the effective `jobPrefix` of the World that
created it (new nullable `workflow_runs.job_prefix`, migration 0026), on
both creation paths: an explicit `run_created` and the resilient
`run_started` that recreates a run from its queued message. `start()`
lists only the active runs stamped with its own prefix, plus unstamped
runs (created before the column existed, or by storage built without a
World), which every World keeps recovering as before. The public
`runs.list` stays unscoped, and the stamp is stripped from returned runs.

The queue's task names and the stamp share one `resolveJobPrefix()`, so
a recovered run always lands in the task whose runner claims it.

Refs #2978 (the recovery half).

Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…databases

The Postgres World page said `WORKFLOW_POSTGRES_JOB_PREFIX` was useful
when sharing a database, but recovery ignored it. Say what it now does:
a World claims and recovers only its own prefix's runs, runs from before
migration 0026 are recovered by every World, and the topic namespace does
not separate apps. Give the SQL that assigns unstamped runs to one app or
moves active runs after a prefix rename.

Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
example-nextjs-workflow-turbopack Ready Ready Preview, v0 Oct 30, 2026 7:51pm UTC
example-nextjs-workflow-webpack Ready Ready Preview, v0 Oct 30, 2026 7:51pm UTC
example-workflow Ready Ready Preview, v0 Oct 30, 2026 7:51pm UTC
workbench-astro-workflow Ready Ready Preview, v0 Oct 30, 2026 7:51pm UTC
workbench-express-workflow Ready Ready Preview, v0 Oct 30, 2026 7:51pm UTC
workbench-fastify-workflow Ready Ready Preview, v0 Oct 30, 2026 7:51pm UTC
workbench-hono-workflow Ready Ready Preview, v0 Oct 30, 2026 7:51pm UTC
workbench-nestjs-workflow Ready Ready Preview, v0 Oct 30, 2026 7:51pm UTC
workbench-nitro-workflow Ready Ready Preview, v0 Oct 30, 2026 7:51pm UTC
workbench-nuxt-workflow Ready Ready Preview, v0 Oct 30, 2026 7:51pm UTC
workbench-python-workflow Ready Ready Preview, v0 Oct 30, 2026 7:51pm UTC
workbench-sveltekit-workflow Ready Ready Preview, v0 Oct 30, 2026 7:51pm UTC
workbench-tanstack-start-workflow Ready Ready Preview, v0 Oct 30, 2026 7:51pm UTC
workbench-vite-workflow Ready Ready Preview, v0 Oct 30, 2026 7:51pm UTC
workflow-docs Ready Ready Preview, v0 Oct 30, 2026 7:51pm UTC
workflow-swc-playground Ready Ready Preview, v0 Oct 30, 2026 7:51pm UTC
workflow-tarballs Ready Ready Preview, v0 Oct 30, 2026 7:51pm UTC
workflow-web Ready Ready Preview, v0 Oct 30, 2026 7:51pm UTC

@changeset-bot

changeset-bot Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4c54f84

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@workflow/world-postgres Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

✅ All tests passed

⚠️ Flaky E2E Tests (passed on retry)

These tests failed at least once and passed on a retry. A recurring entry here is a real race worth investigating.

51 flaky tests
  • parallelStepsThenWebhookWorkflow - no hook_conflict from same-tick replay race (example · vercel-prod / vercel / quickjs / production) — flaked in 8 jobs
  • parallelStepsThenWebhookWorkflow - no hook_conflict from same-tick replay race (express · vercel-prod / vercel / node / production) — flaked in 7 jobs
  • addTenWorkflow (astro · vercel-prod / vercel / node / production) — flaked in 4 jobs
  • addTenWorkflow (express · vercel-ws-transport / vercel / node / ws) — flaked in 4 jobs
  • hookWorkflow (astro · vercel-prod / vercel / node / production) — flaked in 4 jobs
  • hookWorkflow (astro · vercel-prod / vercel / quickjs / production) — flaked in 4 jobs
  • nullByteWorkflow (express · vercel-prod / vercel / quickjs / production) — flaked in 4 jobs
  • sleepingWorkflow (astro · vercel-prod / vercel / quickjs / production) — flaked in 4 jobs
  • promiseAnyWorkflow (example · vercel-prod / vercel / quickjs / production) — flaked in 3 jobs
  • retainedInterleavingWorkflow (nest · vercel-prod / vercel / quickjs / production) — flaked in 3 jobs
  • addTenWorkflow (unknown) — flaked in 2 jobs
  • getTailIndex returns -1 before any chunks are written (express · vercel-prod / vercel / quickjs / production) — flaked in 2 jobs
  • getTailIndex returns correct index after stream completes (hono · vercel-prod / vercel / node / production) — flaked in 2 jobs
  • hookWorkflow is not resumable via public webhook endpoint (example · vercel-prod / vercel / quickjs / production) — flaked in 2 jobs
  • negative startIndex (reads from end) (fastify · vercel-prod / vercel / node / production) — flaked in 2 jobs
  • no startIndex (reads all chunks) (astro · vercel-prod / vercel / quickjs / production) — flaked in 2 jobs
  • no startIndex (reads all chunks) (express · vercel-prod / vercel / node / production) — flaked in 2 jobs
  • positive startIndex (skips first chunk) (fastify · vercel-prod / vercel / quickjs / production) — flaked in 2 jobs
  • promiseAnyWorkflow (nextjs-webpack · vercel-prod / vercel / node / production) — flaked in 2 jobs
  • retainedInterleavingWorkflow (hono · vercel-http-transport / vercel / node / http) — flaked in 2 jobs
  • retainedInterleavingWorkflow (nextjs-turbopack · vercel-prod / vercel / node / production) — flaked in 2 jobs
  • sleepWinsRaceWorkflow (astro · vercel-prod / vercel / quickjs / production) — flaked in 2 jobs
  • webhookWorkflow (example · vercel-prod / vercel / quickjs / production) — flaked in 2 jobs
  • workflowAndStepMetadataWorkflow (express · vercel-prod / vercel / node / production) — flaked in 2 jobs
  • addTenWorkflow (nitro · vercel-http-transport / vercel / node / http)
  • basic text response (nextjs-webpack · vercel-prod / vercel / node / production)
  • fires constructor + stream callbacks in order with event data (nextjs-turbopack · vercel-http-transport / vercel / node / http)
  • getChunks returns same content as reading the stream (hono · vercel-prod / vercel / node / production)
  • getChunks returns same content as reading the stream (vite · vercel-prod / vercel / quickjs / production)
  • getTailIndex returns -1 before any chunks are written (express · vercel-prod / vercel / node / production)
  • getTailIndex returns correct index after stream completes (vite · vercel-prod / vercel / quickjs / production)
  • hookWorkflow (nextjs-turbopack · vercel-http-transport / vercel / node / http)
  • hookWorkflow is not resumable via public webhook endpoint (nest · vercel-prod / vercel / node / production)
  • hookWorkflow is not resumable via public webhook endpoint (vite · vercel-ws-transport / vercel / node / ws)
  • outputStreamInsideStepWorkflow - getWritable() called inside step functions (express · vercel-prod / vercel / quickjs / production)
  • outputStreamInsideStepWorkflow - getWritable() called inside step functions (python · vercel-prod / vercel / node / production)
  • parallelSleepWorkflow (nest · vercel-prod / vercel / node / production)
  • parallelSleepWorkflow (nextjs-turbopack · vercel-prod / vercel / quickjs / production)
  • parallelSleepWorkflow (unknown)
  • positive startIndex (skips first chunk) (vite · vercel-prod / vercel / node / production)
  • promiseAnyWorkflow (express · vercel-ws-transport / vercel / node / ws)
  • promiseRaceWorkflow (nextjs-turbopack · vercel-prod / vercel / quickjs / production)
  • retainedInterleavingWorkflow (unknown)
  • sleepingWorkflow (sveltekit · vercel-prod / vercel / node / production)
  • sleepWinsRaceWorkflow (vite · vercel-prod / vercel / node / production)
  • stamps the spec version the lane opts in to (astro · vercel-prod / vercel / node / production)
  • stamps the spec version the lane opts in to (express · vercel-ws-transport / vercel / node / ws)
  • start({ region: sfo1 }) mints a tagged run ID and executes there (nextjs-turbopack · vercel-multi-region / vercel / node / multi-region)
  • stepWinsRaceWorkflow (tanstack-start · vercel-prod / vercel / quickjs / production)
  • utf8StreamWorkflow (vite · vercel-prod / vercel / quickjs / production)
  • wellKnownAgentWorkflow (.well-known/agent) (unknown)

🛠 Infra Events (absorbed by the harness)

Platform anomalies the e2e harness detected and worked around (e.g. a run the queue never picked up, replaced by a fresh run). Clustered timestamps indicate a backend blip; a steady drip indicates a platform issue worth escalating.

25 infra events
  • run-pickup-stall · promiseAnyWorkflow (nuxt) · at 19:51:34Z · abandoned wrun_41M675TBG60GVMZ6PT68A9PN1Y
  • run-pickup-stall · stamps the spec version the lane opts in to (astro) · at 19:51:36Z · abandoned wrun_41M675TD140GG7NZ0F5BDJW40R
  • run-pickup-stall · runs app-generated source against a registered step (astro) · at 19:51:36Z · abandoned wrun_41M675TB6K0GWPZ9HGDDD0V1F2
  • run-pickup-stall · derives the same workflow id for the same generated source (nitro) · at 19:51:37Z · abandoned wrun_41M675TEW20GHM2KED1K7KQEQG
  • run-pickup-stall · runs app-generated source against a registered step (sveltekit) · at 19:51:38Z · abandoned wrun_41M675TECB0GM3TF5N006K7VT8
  • run-pickup-stall · runs app-generated source against a registered step (express) · at 19:51:41Z · abandoned wrun_41M675TGMK0GR3ERBD1MZHWYAY
  • run-pickup-stall · negative startIndex (reads from end) (fastify) · at 19:51:48Z · abandoned wrun_41M675TRR90GP6W50PRPQ5GYTW
  • run-pickup-stall · promiseAnyWorkflow (sveltekit) · at 19:51:48Z · abandoned wrun_41M675TSAW0GYN239ZJAM7N1AP
  • run-pickup-stall · webhookWorkflow (nest) · at 19:51:51Z · abandoned wrun_41M675TVJZ0GNZY5K9G4QNP80R
  • run-pickup-stall · retainedInterleavingWorkflow (nextjs-turbopack) · at 19:51:53Z · abandoned wrun_41M675TXHY0GT6BN599NK2H3NR
  • run-pickup-stall · retainedInterleavingWorkflow (express) · at 19:51:55Z · abandoned wrun_41M675TZ8W0GWPAR5A8Q33KDQ3
  • run-pickup-stall · getTailIndex returns correct index after stream completes (hono) · at 19:51:56Z · abandoned wrun_41M675V0PP0GN0FH1EZHTKVKDZ
  • run-pickup-stall · runs app-generated source against a registered step (nextjs-webpack) · at 19:52:01Z · abandoned wrun_41M675V3Q30GGKXKGJWJ7EAPRN
  • run-pickup-stall · getTailIndex returns correct index after stream completes (vite) · at 19:52:03Z · abandoned wrun_41M675V7B70GX9GCH0E372K3YC
  • run-pickup-stall · addTenWorkflow (nextjs-webpack) · at 19:52:05Z · abandoned wrun_41M675V8QF0GN2RWCECA7P0S3X
  • run-pickup-stall · writableForwardedFromWorkflowWorkflow (fastify) · at 19:52:07Z · abandoned wrun_41M675VB8J0GS8AE4ZHVPJ9E0Y
  • run-pickup-stall · no startIndex (reads all chunks) (nuxt) · at 19:52:08Z · abandoned wrun_41M675VC5F0GSCXTFD9DXXSSWE
  • run-pickup-stall · webhookWorkflow (astro) · at 19:52:09Z · abandoned wrun_41M675VDNB0GSPH2Y7R2V2DG0A
  • run-pickup-stall · getChunks returns same content as reading the stream (vite) · at 19:52:11Z · abandoned wrun_41M675VESG0GV7J97JX70P8TW6
  • run-pickup-stall · promiseAnyWorkflow (nextjs-webpack) · at 19:52:11Z · abandoned wrun_41M675VEEA0GZ9QGFWRJ8S6F5A
  • run-pickup-stall · stores the generated code with the child run (example) · at 19:52:21Z · abandoned wrun_41M675VSFD0GYV150WFBH1DJEX
  • run-pickup-stall · runs app-generated source against a registered step (tanstack-start) · at 19:53:55Z · abandoned wrun_01M67601VQCZ37C7TQ8XGNAZ49
  • cold-start-warmup · suite warmup (tanstack-start) · at 19:54:08Z · abandoned wrun_01M67601VZCRRXXS5GSRVEYXX2
  • run-pickup-stall · ChainableService.processWithThis - static step methods using this to reference the class (nitro) · at 19:55:11Z · abandoned wrun_01M6762C5Z49F9PE2AMZZ280WN
  • run-pickup-stall · hookCleanupTestWorkflow - hook token reuse after workflow completion (nextjs-webpack) · at 19:59:47Z · abandoned wrun_01M676ASS7HKTMRSJRNH309ZGB

E2E Test Summary

Summary
Passed Failed Skipped Total
✅ ▲ Vercel Production 4008 0 1392 5400
✅ 💻 Local Development 5249 0 551 5800
✅ 📦 Local Production 5249 0 551 5800
✅ 🐘 Local Postgres 5249 0 551 5800
✅ 🪟 Windows 388 0 12 400
✅ 🌐 Cross-language Conformance 68 0 107 175
✅ dynamic-runs 0 0 0 0
✅ vercel-http-transport 903 0 297 1200
✅ vercel-multi-region 27 0 0 27
✅ vercel-spec9 344 0 56 400
✅ vercel-ws-transport 611 0 189 800
Total 22096 0 3706 25802
Details by Category

✅ ▲ Vercel Production

App Passed Failed Skipped
✅ astro-node 146 0 54
✅ astro-quickjs 146 0 54
✅ example-node 146 0 54
✅ example-quickjs 146 0 54
✅ express-node 146 0 54
✅ express-quickjs 146 0 54
✅ fastify-node 146 0 54
✅ fastify-quickjs 146 0 54
✅ hono-node 146 0 54
✅ hono-quickjs 146 0 54
✅ nest-node 146 0 54
✅ nest-quickjs 146 0 54
✅ nextjs-turbopack-node 173 0 27
✅ nextjs-turbopack-quickjs 173 0 27
✅ nextjs-webpack-node 173 0 27
✅ nextjs-webpack-quickjs 173 0 27
✅ nitro-node 146 0 54
✅ nitro-quickjs 146 0 54
✅ nuxt-node 146 0 54
✅ nuxt-quickjs 146 0 54
✅ python-node 66 0 134
✅ sveltekit-node 165 0 35
✅ sveltekit-quickjs 165 0 35
✅ tanstack-start-node 146 0 54
✅ tanstack-start-quickjs 146 0 54
✅ vite-node 146 0 54
✅ vite-quickjs 146 0 54

✅ 💻 Local Development

App Passed Failed Skipped
✅ astro-stable-node 171 0 29
✅ astro-stable-quickjs 171 0 29
✅ express-stable-node 171 0 29
✅ express-stable-quickjs 171 0 29
✅ fastify-stable-node 171 0 29
✅ fastify-stable-quickjs 171 0 29
✅ hono-stable-node 171 0 29
✅ hono-stable-quickjs 171 0 29
✅ nest-stable-node 171 0 29
✅ nest-stable-quickjs 171 0 29
✅ nextjs-turbopack-canary-node 199 0 1
✅ nextjs-turbopack-canary-quickjs 199 0 1
✅ nextjs-turbopack-quickjs-snapshot 199 0 1
✅ nextjs-turbopack-stable-node 199 0 1
✅ nextjs-turbopack-stable-quickjs 199 0 1
✅ nextjs-webpack-canary-node 199 0 1
✅ nextjs-webpack-canary-quickjs 199 0 1
✅ nextjs-webpack-stable-node 199 0 1
✅ nextjs-webpack-stable-quickjs 199 0 1
✅ nitro-stable-node 171 0 29
✅ nitro-stable-quickjs 171 0 29
✅ nuxt-stable-node 171 0 29
✅ nuxt-stable-quickjs 171 0 29
✅ sveltekit-stable-node 190 0 10
✅ sveltekit-stable-quickjs 190 0 10
✅ tanstack-start-node 171 0 29
✅ tanstack-start-quickjs 171 0 29
✅ vite-stable-node 171 0 29
✅ vite-stable-quickjs 171 0 29

✅ 📦 Local Production

App Passed Failed Skipped
✅ astro-stable-node 171 0 29
✅ astro-stable-quickjs 171 0 29
✅ express-stable-node 171 0 29
✅ express-stable-quickjs 171 0 29
✅ fastify-stable-node 171 0 29
✅ fastify-stable-quickjs 171 0 29
✅ hono-stable-node 171 0 29
✅ hono-stable-quickjs 171 0 29
✅ nest-stable-node 171 0 29
✅ nest-stable-quickjs 171 0 29
✅ nextjs-turbopack-canary-node 199 0 1
✅ nextjs-turbopack-canary-quickjs 199 0 1
✅ nextjs-turbopack-quickjs-snapshot 199 0 1
✅ nextjs-turbopack-stable-node 199 0 1
✅ nextjs-turbopack-stable-quickjs 199 0 1
✅ nextjs-webpack-canary-node 199 0 1
✅ nextjs-webpack-canary-quickjs 199 0 1
✅ nextjs-webpack-stable-node 199 0 1
✅ nextjs-webpack-stable-quickjs 199 0 1
✅ nitro-stable-node 171 0 29
✅ nitro-stable-quickjs 171 0 29
✅ nuxt-stable-node 171 0 29
✅ nuxt-stable-quickjs 171 0 29
✅ sveltekit-stable-node 190 0 10
✅ sveltekit-stable-quickjs 190 0 10
✅ tanstack-start-node 171 0 29
✅ tanstack-start-quickjs 171 0 29
✅ vite-stable-node 171 0 29
✅ vite-stable-quickjs 171 0 29

✅ 🐘 Local Postgres

App Passed Failed Skipped
✅ astro-stable-node 171 0 29
✅ astro-stable-quickjs 171 0 29
✅ express-stable-node 171 0 29
✅ express-stable-quickjs 171 0 29
✅ fastify-stable-node 171 0 29
✅ fastify-stable-quickjs 171 0 29
✅ hono-stable-node 171 0 29
✅ hono-stable-quickjs 171 0 29
✅ nest-stable-node 171 0 29
✅ nest-stable-quickjs 171 0 29
✅ nextjs-turbopack-canary-node 199 0 1
✅ nextjs-turbopack-canary-quickjs 199 0 1
✅ nextjs-turbopack-quickjs-snapshot 199 0 1
✅ nextjs-turbopack-stable-node 199 0 1
✅ nextjs-turbopack-stable-quickjs 199 0 1
✅ nextjs-webpack-canary-node 199 0 1
✅ nextjs-webpack-canary-quickjs 199 0 1
✅ nextjs-webpack-stable-node 199 0 1
✅ nextjs-webpack-stable-quickjs 199 0 1
✅ nitro-stable-node 171 0 29
✅ nitro-stable-quickjs 171 0 29
✅ nuxt-stable-node 171 0 29
✅ nuxt-stable-quickjs 171 0 29
✅ sveltekit-stable-node 190 0 10
✅ sveltekit-stable-quickjs 190 0 10
✅ tanstack-start-node 171 0 29
✅ tanstack-start-quickjs 171 0 29
✅ vite-stable-node 171 0 29
✅ vite-stable-quickjs 171 0 29

✅ 🪟 Windows

App Passed Failed Skipped
✅ nextjs-turbopack-node 194 0 6
✅ nextjs-turbopack-quickjs 194 0 6

✅ 🌐 Cross-language Conformance

App Passed Failed Skipped
✅ python 68 0 107

✅ dynamic-runs

App Passed Failed Skipped
✅ astro-vercel 0 0 0
✅ example-vercel 0 0 0
✅ express-vercel 0 0 0
✅ fastify-vercel 0 0 0
✅ hono-vercel 0 0 0
✅ nest-vercel 0 0 0
✅ nextjs-turbopack-vercel 0 0 0
✅ nextjs-webpack-vercel 0 0 0
✅ nitro-vercel 0 0 0
✅ nuxt-vercel 0 0 0
✅ sveltekit-vercel 0 0 0
✅ tanstack-start-vercel 0 0 0
✅ vite-vercel 0 0 0

✅ vercel-http-transport

App Passed Failed Skipped
✅ example 146 0 54
✅ express 146 0 54
✅ hono 146 0 54
✅ nextjs-turbopack 173 0 27
✅ nitro 146 0 54
✅ vite 146 0 54

✅ vercel-multi-region

App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

✅ vercel-spec9

App Passed Failed Skipped
✅ nextjs-turbopack-node 172 0 28
✅ nextjs-turbopack-quickjs 172 0 28

✅ vercel-ws-transport

App Passed Failed Skipped
✅ example 146 0 54
✅ express 146 0 54
✅ nextjs-turbopack 173 0 27
✅ vite 146 0 54

📋 View full workflow run

@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 4c54f84 · Fri, 30 Oct 2026 19:58:30 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1674 (+345%) 🔻 2353 🔴 (+39%) 🔻 2405 🔴 (+34%) 🔻 2790 🔴 (+29%) 🔻 30
TTFS stream 1791 (+416%) 🔻 2303 🔴 (+45%) 🔻 2324 🔴 (+41%) 🔻 2362 🔴 (+30%) 🔻 30
TTFS hook + stream 2070 (+176%) 🔻 2175 🔴 (+15%) 🔻 2279 🔴 (+9.1%) 5196 🔴 (+134%) 🔻 30
Fan-out TTFS Promise.all(100 steps) 568 (-4.7%) 749 (-20%) 💚 2202 (+104%) 🔻 2670 (+40%) 🔻 10
Fan-out TTLS Promise.all(100 steps) 1398 (-47%) 💚 4024 (-11%) 4105 (-14%) 7458 (+48%) 🔻 10
STSO 1020 steps (inline) 72 (-36%) 💚 93 (-38%) 💚 110 (-42%) 💚 155 (-67%) 💚 1019
WO 1020 steps 93993 (-38%) 💚 93993 (-38%) 💚 93993 (-38%) 💚 93993 (-38%) 💚 1
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 150495ms → this run 93674ms (Δ -56821ms, -38%)

 50-100 ms  ░░░░░░░░░░░░░░░░░░░░░░░┃  main   0  this 867  +867
100-150 ms  ███┃█████████████████     main 754  this 139  -615
150-200 ms  ┃████                     main 183  this   9  -174
200-250 ms  ┃                         main  42  this   1   -41
250-300 ms  ┃                         main  16  this   2   -14
300-350 ms  ┃                         main   4  this   0    -4
350-400 ms  ┃                         main   5  this   1    -4
400-450 ms  ┃                         main   2  this   0    -2
450-500 ms  ┃                         main   4  this   0    -4
500-550 ms  ┃                         main   3  this   0    -3
550-600 ms  ┃                         main   2  this   0    -2
700-750 ms  ┃                         main   2  this   0    -2
800-850 ms  ┃                         main   1  this   0    -1
850-900 ms  ┃                         main   1  this   0    -1
📜 Previous results (6)

b6efee6

Fri, 30 Oct 2026 18:36:53 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1913 (+490%) 🔻 2005 🔴 (+25%) 🔻 2029 🔴 (+12%) 2339 🔴 (+23%) 🔻 30
TTFS stream 1599 (+417%) 🔻 2003 🔴 (+8.6%) 2020 🔴 (+6.3%) 2123 🔴 (+8.0%) 30
TTFS hook + stream 2175 (+269%) 🔻 2298 🔴 (+25%) 🔻 2468 🔴 (+24%) 🔻 2637 🔴 (+22%) 🔻 30
Fan-out TTFS Promise.all(100 steps) 570 (-2.4%) 661 (-23%) 💚 691 (-34%) 💚 2329 (+25%) 🔻 10
Fan-out TTLS Promise.all(100 steps) 3461 (-7.1%) 4128 (-6.5%) 4173 (-7.4%) 5073 (+6.5%) 10
STSO 1020 steps (inline) 84 (-28%) 💚 105 (-48%) 💚 126 (-51%) 💚 159 (-66%) 💚 1019
WO 1020 steps 104887 (-43%) 💚 104887 (-43%) 💚 104887 (-43%) 💚 104887 (-43%) 💚 1

96c3e47

Fri, 30 Oct 2026 16:02:32 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1869 (+65%) 🔻 2017 🔴 (+18%) 🔻 2035 🔴 (+18%) 🔻 2291 🔴 (+25%) 🔻 30
TTFS stream 1881 (+34%) 🔻 1948 🔴 (+12%) 1988 🔴 (+12%) 2161 🔴 (+8.8%) 30
TTFS hook + stream 554 (-19%) 💚 2266 🔴 (+7.5%) 2397 🔴 (+11%) 2496 🔴 (-4.8%) 30
Fan-out TTFS Promise.all(100 steps) 688 (+22%) 🔻 984 (+10%) 2213 (+130%) 🔻 2638 (+30%) 🔻 10
Fan-out TTLS Promise.all(100 steps) 1438 (-43%) 💚 4653 (+3.6%) 7639 (+66%) 🔻 9029 (+91%) 🔻 10
STSO 1020 steps (inline) 92 (+53%) 🔻 122 (+5.2%) 141 (±0%) 197 (-12%) 1019
WO 1020 steps 120202 (+6.4%) 120202 (+6.4%) 120202 (+6.4%) 120202 (+6.4%) 1

188e167

Thu, 15 Oct 2026 03:00:50 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 324 (-26%) 💚 1983 🔴 (+34%) 🔻 1988 🔴 (+30%) 🔻 2203 🔴 (+3.6%) 30
TTFS stream 330 (-76%) 💚 1903 🔴 (+33%) 🔻 1942 🔴 (+34%) 🔻 2105 🔴 (+43%) 🔻 30
TTFS hook + stream 773 (-52%) 💚 2722 🔴 (+57%) 🔻 2765 🔴 (+55%) 🔻 5482 🔴 (+168%) 🔻 30
Fan-out TTFS Promise.all(100 steps) 582 (+17%) 🔻 787 (+25%) 🔻 1944 (+160%) 🔻 2827 (+61%) 🔻 10
Fan-out TTLS Promise.all(100 steps) 3451 (+77%) 🔻 4204 (+3.2%) 5406 (+31%) 🔻 7707 (+26%) 🔻 10
STSO 1020 steps (inline) 86 (-6.5%) 115 (-0.9%) 132 (-11%) 218 (-58%) 💚 1019
WO 1020 steps 114359 (-11%) 114359 (-11%) 114359 (-11%) 114359 (-11%) 1

890b672

Thu, 08 Oct 2026 16:10:15 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1872 (+24%) 🔻 1924 🔴 (+1.7%) 1939 🔴 (-2.0%) 2425 🔴 (+21%) 🔻 30
TTFS stream 295 (-71%) 💚 1954 🔴 (+32%) 🔻 1989 🔴 (+33%) 🔻 2385 🔴 (+42%) 🔻 30
TTFS hook + stream 496 (-61%) 💚 2169 🔴 (+23%) 🔻 2190 🔴 (+21%) 🔻 2548 🔴 (+31%) 🔻 30
Fan-out TTFS Promise.all(100 steps) 770 (+43%) 🔻 2457 (+197%) 🔻 2485 (+176%) 🔻 2922 (+60%) 🔻 10
Fan-out TTLS Promise.all(100 steps) 2956 (+89%) 🔻 5225 (-19%) 💚 7432 (+7.0%) 8702 (+16%) 🔻 10
STSO 1020 steps (inline) 96 (+10%) 131 (+21%) 🔻 156 (+22%) 🔻 250 (+45%) 🔻 1019
WO 1020 steps 128128 (+17%) 🔻 128128 (+17%) 🔻 128128 (+17%) 🔻 128128 (+17%) 🔻 1

6e5aa33

Thu, 08 Oct 2026 06:30:30 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 2309 (+122%) 🔻 2445 🔴 (+68%) 🔻 2488 🔴 (+61%) 🔻 2522 🔴 (+36%) 🔻 30
TTFS stream 2110 (+54%) 🔻 2364 🔴 (+64%) 🔻 2389 🔴 (+64%) 🔻 2446 🔴 (+51%) 🔻 30
TTFS hook + stream 2492 (+53%) 🔻 2575 🔴 (+49%) 🔻 2595 🔴 (+42%) 🔻 2836 🔴 (-36%) 💚 30
Fan-out TTFS Promise.all(100 steps) 490 (±0%) 638 (-64%) 💚 878 (-54%) 💚 2802 (+37%) 🔻 10
Fan-out TTLS Promise.all(100 steps) 5660 (+366%) 🔻 8434 (+32%) 🔻 8979 (+22%) 🔻 9109 (+17%) 🔻 10
STSO 1020 steps (inline) 82 (+15%) 🔻 113 (+2.7%) 137 (+5.4%) 209 (+24%) 🔻 1019
WO 1020 steps 113203 (±0%) 113203 (±0%) 113203 (±0%) 113203 (±0%) 1

1a68149

Thu, 08 Oct 2026 06:03:36 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1488 (+43%) 🔻 1922 🔴 (+32%) 🔻 1932 🔴 (+25%) 🔻 1965 🔴 (+6.3%) 30
TTFS stream 1793 (+31%) 🔻 1880 🔴 (+31%) 🔻 1899 🔴 (+31%) 🔻 2046 🔴 (+27%) 🔻 30
TTFS hook + stream 2065 (+27%) 🔻 2195 🔴 (+27%) 🔻 2215 🔴 (+21%) 🔻 2396 🔴 (-46%) 💚 30
Fan-out TTFS Promise.all(100 steps) 508 (+3.9%) 766 (-57%) 💚 798 (-58%) 💚 2270 (+11%) 10
Fan-out TTLS Promise.all(100 steps) 1494 (+23%) 🔻 5003 (-21%) 💚 8662 (+18%) 🔻 8822 (+13%) 10
STSO 1020 steps (inline) 93 (+31%) 🔻 115 (+4.5%) 131 (+0.8%) 177 (+5.4%) 1019
WO 1020 steps 114386 (+1.2%) 114386 (+1.2%) 114386 (+1.2%) 114386 (+1.2%) 1
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap, split inline (same warm process — pure framework overhead) vs queue-hop (fresh process — dispatch, reinit, replay). █ = main, ┃ = this run, ░ = fill.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body) · Fan-out TTFS: fan-out time to first step (in-deployment start() → first of the parallel step bodies to complete) · Fan-out TTLS: fan-out time to last step (in-deployment start() → last of the parallel step bodies to complete, i.e. when the Promise.all resolves) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · Promise.all(100 steps): 100 trivial no-op steps started together in a single Promise.all; Fan-out TTFS is the first of them to complete and Fan-out TTLS the last, both from the in-deployment clientStart, so their gap is the spread the runtime adds across the fan-out

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600

All timestamps are deployment-side; runs are triggered in-deployment, so the CI runner and api.vercel.com sit outside every measured window. TTFS = start() → first step body (includes dispatch + any cold start); Fan-out TTFS/TTLS = first/last step completion of one Promise.all from the same anchor (the gap is the runtime’s fan-out spread); STSO/WO between step bodies. Streaming delivery performance is measured in durabench.

Cold starts stay in the numbers (real bursty-workload latency, inflates P75+); Best is the warm floor.

@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟢 world-sim scenario book — 0 fail of 45 total
scenario outcome events virt replay violations
✅ smoke-no-steps completed 3 0ms ok 0
✅ smoke-one-step completed 5 0ms ok 0
✅ hook-at-step-started completed 9 0ms ok 0
✅ hook-at-step-completed completed 9 0ms ok 0
✅ hook-at-hook-created completed 9 0ms ok 0
✅ claim-hook-at-registration completed 9 0ms ok 0
✅ claim-hook-at-step-started completed 9 0ms ok 0
✅ deadline-hook-wins completed 6 1.0h ok 0
✅ deadline-expires completed 6 1.0h ok 0
✅ step-vs-timer-early-settlement completed 6 1.0h ok 0
✅ long-sleep completed 9 30.0d ok 0
✅ hook-never-arrives stalled 3 0ms skipped 0
✅ step-retries-twice completed 9 2.0s ok 0
✅ parallel-steps completed 7 0ms ok 0
✅ hook-on-execution-state completed 9 0ms ok 0
✅ peek-hook-before-branch completed 9 0ms ok 0
✅ peek-hook-after-branch completed 9 0ms ok 0
✅ peek-hook-at-registration completed 9 0ms ok 0
✅ race-hook-before-probe completed 9 0ms ok 0
✅ race-hook-after-probe completed 9 0ms ok 0
✅ race-duplicate-delivery completed 10 0ms ok 0
✅ attr-hook-before-step completed 9 0ms ok 0
✅ attr-hook-after-step completed 9 0ms ok 0
✅ attr-from-step-body completed 10 0ms ok 0
✅ fork-hook-after-timeout completed 11 1.0m ok 0
✅ fork-hook-before-timeout completed 11 1.0m ok 0
✅ count-hook-after-timeout completed 13 1.0m ok 0
✅ count-hook-before-timeout completed 15 1.0m ok 0
✅ stale-read-step-count-fork completed 15 1.0m ok 0
✅ stale-read-equal-step-counts completed 11 1.0m ok 0
✅ step-vs-step-fork completed 9 0ms ok 0
✅ step-vs-step-fork-fenced completed 9 1.0s ok 0
✅ fence-catches-benign-direction completed 9 1.0s ok 0
✅ run-ahead-fenced-overlap completed 10 1.0s ok 0
✅ in-flight-before-decision completed 15 1.0m ok 0
✅ in-flight-before-decision-counted completed 13 1.0m ok 0
✅ in-flight-after-decision completed 15 2.0m ok 0
✅ stale-read-step-count-fork-fenced completed 15 1.0m ok 0
✅ fork-hook-wins completed 10 1.0m ok 0
✅ fork-timeout-wins completed 10 1.0m ok 0
✅ unclaimed-payload-under-fork completed 14 1.0m ok 0
✅ claimed-payload-under-fork completed 14 1.0m ok 0
✅ writers-independent-step-bodies completed 9 0ms ok 0
✅ writers-scripted-tempo completed 9 0ms ok 0
✅ cancel-mid-step cancelled 6 0ms skipped 0

Full trace: world-sim.txt

@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor
Framework Flow route Step reg. Framework output
hono 271.7 KiB (±0) 100.1 KiB (±0) 2.15 MiB (±0)
nextjs-turbopack 279.1 KiB (±0) 426 B (±0) 998.4 KiB (±0)
About these numbers

Sizes are gzip; parentheses show the change against main.
Flow route and Step reg. gate this job, on raw bytes rather than the gzip shown, at max(2%, 50.0 KiB). Framework output is informational.

4c54f84 · run

pranaygp and others added 2 commits October 7, 2026 23:13
…t by migration number

A run has no job_prefix when an earlier version of the World created it,
including old code still running after the migration was applied, so
"created before migration 0026" was imprecise. Dropping the number also
keeps the docs right if the migration is renumbered when another open
world-postgres migration (#4757 also claims 0026) lands first. The
changeset is trimmed to two sentences.

Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…he environment

The CLI and the observability UI build their World from the environment,
so a run they create (a re-run) is stamped with the prefix they see. With
recovery scoped to the prefix, an app that sets jobPrefix only in code no
longer has such a run swept into its own task on its next boot.

Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* origin/main:
  [deps] Upgrade astro to 7.2.10 for GHSA-26w7-cxv4-gfx2 (#4738)
  [deps] Upgrade next to 16.3.8 for CVE-2026-94483 (#4737)
#4757 also adds a migration 0026, with the same journal "when". Drizzle's
migrator applies only journal entries whose "when" is later than the last
migration recorded in the database, so two entries with one timestamp
cannot both land: whichever is applied second is skipped silently.

This migration becomes 0027, with a "when" one day after #4757's. #4757
has to merge first; this branch then merges main and keeps both journal
entries. In the other order, databases that ran 0027 would skip 0026.

Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

pranaygp added a commit that referenced this pull request Oct 15, 2026
)

### Summary

`@workflow/world-postgres` stores every timestamp column except
`hooks.token_retention_until` as `timestamp without time zone`. On a
Postgres server (or session) that is not in UTC, every `createdAt` the
World returns is off by the UTC offset. This PR moves all timestamp
columns to `timestamp with time zone`, with migration `0027` converting
existing databases and repairing the values that were stored wrong.

### Why

- `created_at` comes from `DEFAULT now()`, which Postgres casts to the
session's local wall time for a naive column. drizzle reads naive
columns back as UTC (`new Date(value + "+0000")`).
- JS-written columns (`started_at`, `completed_at`, `retry_after`, ...)
go in as ISO strings whose `Z` Postgres ignores, so they hold UTC wall
time and read back correctly.
- Result, with the server in `America/Los_Angeles`: every run's and
step's `createdAt` is 7 h **before** its `startedAt`. `Asia/Kolkata`:
5.5 h in the future. A pool with `-c TimeZone=Asia/Tokyo` on a UTC
server: 9 h in the future.
- It is more than cosmetic: `event.createdAt` drives the deterministic
workflow clock (`advanceClock(+event.createdAt)` in both runtimes), so
`Date.now()` inside workflow code stays pinned at run start for hours on
UTC-negative servers and jumps ahead on UTC-positive ones.

Found while building a durable agent harness on top of Workflow whose CI
checks what `workflow inspect` shows for runs on each World: on
world-postgres 5.0.2 with the database in `America/Los_Angeles`,
`createdAt` was 7 h before `startedAt` for every run and step, and
correct with the server in UTC. Present on `main`; no existing issue or
PR covers it.

### What changed

- **`src/drizzle/schema.ts`**: every timestamp column is `timestamp with
time zone`, via a small `timestamptz` custom type with a comment on why.
No storage code changes: ISO `Z` strings and `now()` both land as the
right instant. TS types stay `Date`. The reader parses Postgres'
offset-suffixed output as is, and reads a value with no offset (only an
unconverted column prints one) as UTC, as earlier versions did;
drizzle's own `withTimezone` reader would parse it in the Node process's
local zone.
- **Migration `0027_timestamps_with_time_zone.sql`** (one `DO` block;
restores the session TimeZone it changes):
1. Sets the session to UTC and converts the 21 columns with no `USING`
clause. Postgres 12+ skips the table rewrite for `timestamp` ->
`timestamptz` when TimeZone is UTC, and stored values are taken as UTC,
which is already right for every JS-written column.
2. If the zone the defaults were written in is not a UTC alias, moves
values that only ever came from `now()` from that zone's wall time to
the instant they recorded: `created_at` on runs, events, steps, hooks,
waits, invocations and stream chunks; `updated_at` on runs/steps/waits
only where `updated_at = created_at` (never updated, so both came from
the same `now()`); `invocations.responded_at`. Left alone:
`invocations.expired_at` (mixes `now()` and JS dates, only null-checked)
and `snapshots.created_at` (always written from JS).
3. The legacy zone is the migration session's TimeZone, overridable with
`workflow.legacy_timezone` (e.g.
`?options=-c%20workflow.legacy_timezone%3DEurope%2FBerlin`) for apps
whose pool set a different session zone than `bootstrap` uses. `ALTER
DATABASE/ROLE ... SET workflow.legacy_timezone` works too, for poolers
that drop startup `options`.
4. Runs once. The drizzle migrator takes no lock, so two `bootstrap`
processes started together both apply 0027, and unlike earlier
migrations a second application would not fail: it would move the
repaired values by the offset again. The block locks the 8 tables first
and returns without changes when `workflow_runs.created_at` is already
`timestamptz`. It waits at most 10 s for those locks (unless the session
set its own `lock_timeout`), so a long transaction fails `bootstrap`
instead of queueing every query behind it.
- **`meta/_journal.json`**: entry `idx 27`, `when` one day after main's
`0026_add_seq_in_band`.
- **Package README**: a "Timestamps and time zones" section on the
migration and the override.
- **Docs** (`docs/content/worlds/v5/postgres.mdx`): a warning callout
under Installation. The page says migrations can run as a
post-deployment script; on non-UTC servers 0027 needs the upgrade order
below.
- **Changeset**: `@workflow/world-postgres` patch.
- **Test hygiene**: the new test's pools ignore `57P01` ("terminating
connection due to administrator command"). `pool.end()` resolves before
its sockets close, so stopping the container can still hand that error
to a closing client; pg-pool re-emits it on the pool, and with no
listener vitest counts an uncaught exception and fails the job even
though every test passed. Other errors are still thrown.

### Compatibility and rollout

- **UTC servers** (the default on Neon, Supabase, RDS and the Docker
image): catalog-only ALTER, milliseconds under ACCESS EXCLUSIVE on the 8
tables, plus a rebuild of the small `workflow_invocations_pending`
partial index. No data changes. Postgres 11 and older rewrite the tables
on the ALTER regardless (still correct, slower).
- **Non-UTC servers**: the repair UPDATE rewrites every `created_at` row
(events and stream chunks can be large) while holding those locks, once.
- **Upgrade window.** New code against an unmigrated DB reads naive
timestamps as UTC, as old code did, in any Node time zone. Old code
against a migrated DB reads correctly in a UTC session and shifted by
the session's offset in a non-UTC one. A shifted read moves more than
display: `step_started` compares `retryAfter` with `Date.now()`, so a
retry backoff can be skipped or stretched by hours. On a non-UTC server:
stop the previous version's workers, run `bootstrap`, then start the
upgraded app (the README and the new docs callout say this). For the
same reason there is no rollback past 0027 on a non-UTC server.
- **Assumed zone.** The repair assumes old rows were written in the
migration session's zone; it is wrong if the server zone changed over
time or the app's pool used another zone (hence
`workflow.legacy_timezone`). Wall times in a DST fall-back hour are
ambiguous and can land 1 h off.
- Consumers of `@workflow/world-postgres/schema` see the column SQL type
change; the TS types stay `Date`.

### How did you test your changes?

New `test/timezone.test.ts` (testcontainers `postgres:15-alpine`,
skipped on Windows like the other container tests; per-database time
zones are set with `ALTER DATABASE ... SET timezone`):

- **Fresh schema** with the database in `America/Los_Angeles` and
`Asia/Kolkata`, and a UTC server with a pool setting
`TimeZone=Asia/Tokyo`: drive run/step/hook/wait creation through the
storage API and assert every `createdAt`/`startedAt` (write results,
`runs.get`, `steps.get`, `hooks.get`, `events.list`) is the current
instant.
- **Schema check**: every timestamp column in `workflow` is `timestamp
with time zone` and the `created_at` defaults stay `now()`.
- **Upgrade path**: migrate to 0026 (the last naive migration, current
`main`) only, write rows the way 5.0.x did (defaults plus JS ISO
updates), run all migrations, read back through the drizzle schema. In
`America/Los_Angeles`, `created_at` and never-updated `updated_at` equal
the transaction's real `now()` (and a `created_at` from January is moved
by the standard-time offset, not today's), while JS-written
`started_at`/`updated_at`/`resume_at`/snapshot `created_at` are
unchanged. On a UTC server the same holds and `pg_relation_filenode` of
all 8 tables and their indexes is unchanged except
`workflow_invocations_pending`, whose predicate reads `responded_at` (no
table rewrite). With rows written in a `TimeZone=Asia/Tokyo` session,
`workflow.legacy_timezone=Asia/Tokyo` repairs them (without it they stay
9 h off), set either in the connection's `options` or with `ALTER
DATABASE`.
- **Runs once**: two `runMigrations` in parallel from the 0025 state
(both insert a 0027 row in the migrations table), and the 0027 SQL
executed again on a converted database, leave the repaired values where
one run put them. Without the lock-and-skip guard both fail by
25,200,000 ms.
- **Lock timeout**: with another transaction holding a lock on
`workflow_events`, `bootstrap` fails with `55P03` within the 10 s
timeout, and succeeds once that transaction ends.
- **Reader before the migration**: on an unconverted UTC database with
`process.env.TZ = 'America/Los_Angeles'`,
`retryAfter`/`startedAt`/`createdAt` read back as the instants written
(with drizzle's stock reader `retryAfter` comes back 7 h late).
- **Journal order**: every entry's `idx` matches its file number and its
`when` is later than the one before, so a duplicate `when` like this
branch's pre-merge 0026 fails CI.

Results:

- Latest head (after merging `main` at ecaa047):
`test/timezone.test.ts` 13/13 and the whole package 388/388 on
`postgres:15-alpine`; typecheck and Biome clean on changed files; `node
scripts/check-changesets.mjs` OK. Breaking the reader alone fails only
the pre-migration reader test; restoring the duplicate `when` fails the
journal test and every upgrade test.
- Earlier heads: on `main`'s schema 8 of the 9 tests fail (off by
25,200,000 ms, 19,800,000 ms and 32,400,000 ms; the UTC no-rewrite test
is a guard and passes on both). With this PR: 9/9 on
`postgres:15-alpine`, `postgres:12-alpine` and `postgres:18-alpine` (the
image the `e2e-local-postgres` job uses), and 9/9 on Homebrew PostgreSQL
14.20 after merging the latest `main`.
- After merging `main`: `storage.test.ts`, `retention.test.ts` and
`run-creation.test.ts` 185/185 on PostgreSQL 14.20; `pnpm vitest run
src` 71/71; `pnpm build` and `pnpm typecheck` clean; Biome clean on
changed files; `node scripts/check-changesets.mjs` OK.
- CI: the `Unit Tests (ubuntu-latest)` job failed twice on this branch
with 330/330 tests passing and two unhandled `57P01` errors attributed
to `test/timezone.test.ts` (the teardown race above, now handled in this
file and reproduced in isolation with `pg_terminate_backend` on an idle
pooled client). `main` currently fails the same job the same way from
`test/invoke.test.ts` (e.g. on b5c1bc8), so the job can still go red
from the older test files until they get the same listener; that is
pre-existing and not touched here. On the latest head the job failed
earlier, in `packages/core` (`fanout-dispatch.test.ts` "publishes every
queued branch ... in chunks of at most 16": a wall-clock bound, 270 ms
against `< 260`), which this PR does not touch; it needs a rerun to show
the world-postgres result.
- Earlier full-package runs (`pnpm build && pnpm test`, testcontainers):
330/330 on three runs and 329/330 on one. Each failure was a single
pre-existing timing test that passes in isolation and is unrelated to
this diff: `storage.test.ts` "should release a retained hook after its
deadline" (writes a host `Date.now() - 1` and expects the container's
`now()` to be past it, on the already-`timestamptz`
`token_retention_until`) and `invoke.test.ts` "slow fallback" (3 s
budget under parallel load).
- A scratch test (not committed) wrote rows with `main`'s storage code
on servers in `America/Los_Angeles`, `Asia/Kolkata`, `Europe/London`,
`America/St_Johns` (-02:30) and UTC, ran the migrations, then continued
the same run with the new code: every run/step/hook/wait/event timestamp
is the instant it was written, the event log's `createdAt` stays
monotonic across the upgrade, and `main`'s code reading the migrated
database from a UTC session gets the same instants.

### Docs Preview

| Page | Preview |
| --- | --- |
| Postgres World, Installation (migration 0027 callout) |
[/worlds/postgres#installation](https://workflow-docs-git-pgp-world-postgres-utc-timestamps.vercel.sh/worlds/postgres#installation)
|

### Open questions for reviewers

1. **`timestamptz` or the keep-`timestamp` fallback?** The fallback:
keep `timestamp`, change the defaults to `timezone('utc', now())`, fix
the raw `now()` writes in `invocations.ts`, and keep the same repair
UPDATE. It stays compatible with old readers in any session zone but
leaves the trap for any future `now()` write. This PR prefers the type
change.
2. **Repair under lock.** On non-UTC servers the repair UPDATE runs
inside the migration while holding ACCESS EXCLUSIVE on the 8 tables,
about one rewrite of each table (now documented so operators can plan a
window). Acceptable for a one-time migration, or should the repair be
batched outside the lock?
3. **Changeset level.** Kept as `patch`, per AGENTS.md (`patch` for bug
fixes); the review suggested `minor` given the data-rewriting migration.

### Related PRs and merge order

- `main` shipped its own `0026_add_seq_in_band` (#4821) with the journal
`when` this branch's 0026 had, so this migration is now **`0027`**.
Without the renumber, databases already at main's 0026 would skip the
conversion silently.
- **#4761** (`[world-postgres] Scope startup recovery to the World's
jobPrefix`) also adds `0027_add_run_job_prefix` with the same `when` as
this PR's 0027, and does not have main's 0026 yet. Whichever of the two
merges second has to take `0028` and a later `when`; the journal-order
test in this PR fails on the clash.
- #4756 and #4759 touch world-postgres without migrations and are
independent of this one.

### PR Checklist - Required to merge

- [x] 📦 `pnpm changeset` was run to create a changelog for this PR
- [x] 🔒 DCO sign-off passes (run `git commit --signoff` on your commits)
- [ ] 📝 Ping `@vercel/workflow` in a comment once the PR is ready, and
the above checklist is complete

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
Co-authored-by: Pranay Prakash <1797812+pranaygp@users.noreply.github.com>
main now has 0026_add_seq_in_band and 0027_timestamps_with_time_zone, so the
job_prefix migration becomes 0028 with a later journal `when`. Recovery's
scoped runs list picks up main's `sortOrder`, and the jobPrefix stamping moves
into main's unfenced events storage, which the in-band fence wraps. The
migration uses `ADD COLUMN IF NOT EXISTS`, like 0026, since two concurrent
`bootstrap` processes can both apply it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Co-Authored-By: Pranay Prakash <1797812+pranaygp@users.noreply.github.com>
github-actions Bot added a commit that referenced this pull request Oct 15, 2026
)

`@workflow/world-postgres` stores every timestamp column except
`hooks.token_retention_until` as `timestamp without time zone`. On a
Postgres server (or session) that is not in UTC, every `createdAt` the
World returns is off by the UTC offset. This PR moves all timestamp
columns to `timestamp with time zone`, with migration `0027` converting
existing databases and repairing the values that were stored wrong.

- `created_at` comes from `DEFAULT now()`, which Postgres casts to the
session's local wall time for a naive column. drizzle reads naive
columns back as UTC (`new Date(value + "+0000")`).
- JS-written columns (`started_at`, `completed_at`, `retry_after`, ...)
go in as ISO strings whose `Z` Postgres ignores, so they hold UTC wall
time and read back correctly.
- Result, with the server in `America/Los_Angeles`: every run's and
step's `createdAt` is 7 h **before** its `startedAt`. `Asia/Kolkata`:
5.5 h in the future. A pool with `-c TimeZone=Asia/Tokyo` on a UTC
server: 9 h in the future.
- It is more than cosmetic: `event.createdAt` drives the deterministic
workflow clock (`advanceClock(+event.createdAt)` in both runtimes), so
`Date.now()` inside workflow code stays pinned at run start for hours on
UTC-negative servers and jumps ahead on UTC-positive ones.

Found while building a durable agent harness on top of Workflow whose CI
checks what `workflow inspect` shows for runs on each World: on
world-postgres 5.0.2 with the database in `America/Los_Angeles`,
`createdAt` was 7 h before `startedAt` for every run and step, and
correct with the server in UTC. Present on `main`; no existing issue or
PR covers it.

- **`src/drizzle/schema.ts`**: every timestamp column is `timestamp with
time zone`, via a small `timestamptz` custom type with a comment on why.
No storage code changes: ISO `Z` strings and `now()` both land as the
right instant. TS types stay `Date`. The reader parses Postgres'
offset-suffixed output as is, and reads a value with no offset (only an
unconverted column prints one) as UTC, as earlier versions did;
drizzle's own `withTimezone` reader would parse it in the Node process's
local zone.
- **Migration `0027_timestamps_with_time_zone.sql`** (one `DO` block;
restores the session TimeZone it changes):
1. Sets the session to UTC and converts the 21 columns with no `USING`
clause. Postgres 12+ skips the table rewrite for `timestamp` ->
`timestamptz` when TimeZone is UTC, and stored values are taken as UTC,
which is already right for every JS-written column.
2. If the zone the defaults were written in is not a UTC alias, moves
values that only ever came from `now()` from that zone's wall time to
the instant they recorded: `created_at` on runs, events, steps, hooks,
waits, invocations and stream chunks; `updated_at` on runs/steps/waits
only where `updated_at = created_at` (never updated, so both came from
the same `now()`); `invocations.responded_at`. Left alone:
`invocations.expired_at` (mixes `now()` and JS dates, only null-checked)
and `snapshots.created_at` (always written from JS).
3. The legacy zone is the migration session's TimeZone, overridable with
`workflow.legacy_timezone` (e.g.
`?options=-c%20workflow.legacy_timezone%3DEurope%2FBerlin`) for apps
whose pool set a different session zone than `bootstrap` uses. `ALTER
DATABASE/ROLE ... SET workflow.legacy_timezone` works too, for poolers
that drop startup `options`.
4. Runs once. The drizzle migrator takes no lock, so two `bootstrap`
processes started together both apply 0027, and unlike earlier
migrations a second application would not fail: it would move the
repaired values by the offset again. The block locks the 8 tables first
and returns without changes when `workflow_runs.created_at` is already
`timestamptz`. It waits at most 10 s for those locks (unless the session
set its own `lock_timeout`), so a long transaction fails `bootstrap`
instead of queueing every query behind it.
- **`meta/_journal.json`**: entry `idx 27`, `when` one day after main's
`0026_add_seq_in_band`.
- **Package README**: a "Timestamps and time zones" section on the
migration and the override.
- **Docs** (`docs/content/worlds/v5/postgres.mdx`): a warning callout
under Installation. The page says migrations can run as a
post-deployment script; on non-UTC servers 0027 needs the upgrade order
below.
- **Changeset**: `@workflow/world-postgres` patch.
- **Test hygiene**: the new test's pools ignore `57P01` ("terminating
connection due to administrator command"). `pool.end()` resolves before
its sockets close, so stopping the container can still hand that error
to a closing client; pg-pool re-emits it on the pool, and with no
listener vitest counts an uncaught exception and fails the job even
though every test passed. Other errors are still thrown.

- **UTC servers** (the default on Neon, Supabase, RDS and the Docker
image): catalog-only ALTER, milliseconds under ACCESS EXCLUSIVE on the 8
tables, plus a rebuild of the small `workflow_invocations_pending`
partial index. No data changes. Postgres 11 and older rewrite the tables
on the ALTER regardless (still correct, slower).
- **Non-UTC servers**: the repair UPDATE rewrites every `created_at` row
(events and stream chunks can be large) while holding those locks, once.
- **Upgrade window.** New code against an unmigrated DB reads naive
timestamps as UTC, as old code did, in any Node time zone. Old code
against a migrated DB reads correctly in a UTC session and shifted by
the session's offset in a non-UTC one. A shifted read moves more than
display: `step_started` compares `retryAfter` with `Date.now()`, so a
retry backoff can be skipped or stretched by hours. On a non-UTC server:
stop the previous version's workers, run `bootstrap`, then start the
upgraded app (the README and the new docs callout say this). For the
same reason there is no rollback past 0027 on a non-UTC server.
- **Assumed zone.** The repair assumes old rows were written in the
migration session's zone; it is wrong if the server zone changed over
time or the app's pool used another zone (hence
`workflow.legacy_timezone`). Wall times in a DST fall-back hour are
ambiguous and can land 1 h off.
- Consumers of `@workflow/world-postgres/schema` see the column SQL type
change; the TS types stay `Date`.

New `test/timezone.test.ts` (testcontainers `postgres:15-alpine`,
skipped on Windows like the other container tests; per-database time
zones are set with `ALTER DATABASE ... SET timezone`):

- **Fresh schema** with the database in `America/Los_Angeles` and
`Asia/Kolkata`, and a UTC server with a pool setting
`TimeZone=Asia/Tokyo`: drive run/step/hook/wait creation through the
storage API and assert every `createdAt`/`startedAt` (write results,
`runs.get`, `steps.get`, `hooks.get`, `events.list`) is the current
instant.
- **Schema check**: every timestamp column in `workflow` is `timestamp
with time zone` and the `created_at` defaults stay `now()`.
- **Upgrade path**: migrate to 0026 (the last naive migration, current
`main`) only, write rows the way 5.0.x did (defaults plus JS ISO
updates), run all migrations, read back through the drizzle schema. In
`America/Los_Angeles`, `created_at` and never-updated `updated_at` equal
the transaction's real `now()` (and a `created_at` from January is moved
by the standard-time offset, not today's), while JS-written
`started_at`/`updated_at`/`resume_at`/snapshot `created_at` are
unchanged. On a UTC server the same holds and `pg_relation_filenode` of
all 8 tables and their indexes is unchanged except
`workflow_invocations_pending`, whose predicate reads `responded_at` (no
table rewrite). With rows written in a `TimeZone=Asia/Tokyo` session,
`workflow.legacy_timezone=Asia/Tokyo` repairs them (without it they stay
9 h off), set either in the connection's `options` or with `ALTER
DATABASE`.
- **Runs once**: two `runMigrations` in parallel from the 0025 state
(both insert a 0027 row in the migrations table), and the 0027 SQL
executed again on a converted database, leave the repaired values where
one run put them. Without the lock-and-skip guard both fail by
25,200,000 ms.
- **Lock timeout**: with another transaction holding a lock on
`workflow_events`, `bootstrap` fails with `55P03` within the 10 s
timeout, and succeeds once that transaction ends.
- **Reader before the migration**: on an unconverted UTC database with
`process.env.TZ = 'America/Los_Angeles'`,
`retryAfter`/`startedAt`/`createdAt` read back as the instants written
(with drizzle's stock reader `retryAfter` comes back 7 h late).
- **Journal order**: every entry's `idx` matches its file number and its
`when` is later than the one before, so a duplicate `when` like this
branch's pre-merge 0026 fails CI.

Results:

- Latest head (after merging `main` at ecaa047):
`test/timezone.test.ts` 13/13 and the whole package 388/388 on
`postgres:15-alpine`; typecheck and Biome clean on changed files; `node
scripts/check-changesets.mjs` OK. Breaking the reader alone fails only
the pre-migration reader test; restoring the duplicate `when` fails the
journal test and every upgrade test.
- Earlier heads: on `main`'s schema 8 of the 9 tests fail (off by
25,200,000 ms, 19,800,000 ms and 32,400,000 ms; the UTC no-rewrite test
is a guard and passes on both). With this PR: 9/9 on
`postgres:15-alpine`, `postgres:12-alpine` and `postgres:18-alpine` (the
image the `e2e-local-postgres` job uses), and 9/9 on Homebrew PostgreSQL
14.20 after merging the latest `main`.
- After merging `main`: `storage.test.ts`, `retention.test.ts` and
`run-creation.test.ts` 185/185 on PostgreSQL 14.20; `pnpm vitest run
src` 71/71; `pnpm build` and `pnpm typecheck` clean; Biome clean on
changed files; `node scripts/check-changesets.mjs` OK.
- CI: the `Unit Tests (ubuntu-latest)` job failed twice on this branch
with 330/330 tests passing and two unhandled `57P01` errors attributed
to `test/timezone.test.ts` (the teardown race above, now handled in this
file and reproduced in isolation with `pg_terminate_backend` on an idle
pooled client). `main` currently fails the same job the same way from
`test/invoke.test.ts` (e.g. on b5c1bc8), so the job can still go red
from the older test files until they get the same listener; that is
pre-existing and not touched here. On the latest head the job failed
earlier, in `packages/core` (`fanout-dispatch.test.ts` "publishes every
queued branch ... in chunks of at most 16": a wall-clock bound, 270 ms
against `< 260`), which this PR does not touch; it needs a rerun to show
the world-postgres result.
- Earlier full-package runs (`pnpm build && pnpm test`, testcontainers):
330/330 on three runs and 329/330 on one. Each failure was a single
pre-existing timing test that passes in isolation and is unrelated to
this diff: `storage.test.ts` "should release a retained hook after its
deadline" (writes a host `Date.now() - 1` and expects the container's
`now()` to be past it, on the already-`timestamptz`
`token_retention_until`) and `invoke.test.ts` "slow fallback" (3 s
budget under parallel load).
- A scratch test (not committed) wrote rows with `main`'s storage code
on servers in `America/Los_Angeles`, `Asia/Kolkata`, `Europe/London`,
`America/St_Johns` (-02:30) and UTC, ran the migrations, then continued
the same run with the new code: every run/step/hook/wait/event timestamp
is the instant it was written, the event log's `createdAt` stays
monotonic across the upgrade, and `main`'s code reading the migrated
database from a UTC session gets the same instants.

| Page | Preview |
| --- | --- |
| Postgres World, Installation (migration 0027 callout) |
[/worlds/postgres#installation](https://workflow-docs-git-pgp-world-postgres-utc-timestamps.vercel.sh/worlds/postgres#installation)
|

1. **`timestamptz` or the keep-`timestamp` fallback?** The fallback:
keep `timestamp`, change the defaults to `timezone('utc', now())`, fix
the raw `now()` writes in `invocations.ts`, and keep the same repair
UPDATE. It stays compatible with old readers in any session zone but
leaves the trap for any future `now()` write. This PR prefers the type
change.
2. **Repair under lock.** On non-UTC servers the repair UPDATE runs
inside the migration while holding ACCESS EXCLUSIVE on the 8 tables,
about one rewrite of each table (now documented so operators can plan a
window). Acceptable for a one-time migration, or should the repair be
batched outside the lock?
3. **Changeset level.** Kept as `patch`, per AGENTS.md (`patch` for bug
fixes); the review suggested `minor` given the data-rewriting migration.

- `main` shipped its own `0026_add_seq_in_band` (#4821) with the journal
`when` this branch's 0026 had, so this migration is now **`0027`**.
Without the renumber, databases already at main's 0026 would skip the
conversion silently.
- **#4761** (`[world-postgres] Scope startup recovery to the World's
jobPrefix`) also adds `0027_add_run_job_prefix` with the same `when` as
this PR's 0027, and does not have main's 0026 yet. Whichever of the two
merges second has to take `0028` and a later `when`; the journal-order
test in this PR fails on the clash.
- #4756 and #4759 touch world-postgres without migrations and are
independent of this one.

- [x] 📦 `pnpm changeset` was run to create a changelog for this PR
- [x] 🔒 DCO sign-off passes (run `git commit --signoff` on your commits)
- [ ] 📝 Ping `@vercel/workflow` in a comment once the PR is ready, and
the above checklist is complete

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
Co-authored-by: Pranay Prakash <1797812+pranaygp@users.noreply.github.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
@vincenzodomina

Copy link
Copy Markdown

Bump!
This would be great to have!
Thanks

…art-scope

Co-Authored-By: Pranay Prakash <1797812+pranaygp@users.noreply.github.com>
Comment thread docs/content/worlds/v5/postgres.mdx Resolved
Comment thread packages/world-postgres/README.md Resolved
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@vercel
vercel Bot temporarily deployed to Preview – workflow-docs October 30, 2026 18:23 Inactive
@VaguelySerious
VaguelySerious enabled auto-merge (squash) October 30, 2026 18:24
Renumber the job_prefix migration to 0029 after main's 0028_add_start_hook_admissions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@VaguelySerious
VaguelySerious merged commit 8a17d00 into main Oct 30, 2026
329 of 334 checks passed
@VaguelySerious
VaguelySerious deleted the pgp/world-postgres-start-scope branch October 30, 2026 21:41
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 8a17d00 (AI decision).

This fixes real cross-app run takeover during start() recovery. However, the fix works by changing behavior and adding schema: a new workflow_runs.job_prefix column and migration that must be applied with bootstrap before deploying, new run stamping, and a minor changeset. That migration-gated rollout is not suitable for the maintenance line. It is also numbered after main-only migrations (0028_add_start_hook_admissions) and builds on main-only storage code such as sortOrder and start hooks, so a human should force it via workflow_dispatch only if stable users sharing a database need it.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

8a17d00e79f18db0e845f9355684858ac8c76b33

@github-actions github-actions Bot mentioned this pull request Oct 30, 2026
karthikscale3 pushed a commit that referenced this pull request Nov 2, 2026
…5180)

### Description

Follow-up to #5091. That PR documented passing a stored `Hook` to
`resumeHook()` on the `resumeHook` page only. This adds short mentions
where users and World authors look for it:

- **`resumeHook`**: the intro mentions that you can pass a `Hook`, and
the reuse section says the Vercel World supports guarded delivery, while
other Worlds deliver a Hook object through their existing path.
- **`getHookByToken`**: store the returned Hook and pass it to
`resumeHook()` for repeated deliveries.
- **What's new in v5**: one bullet under *Also new in 5.0*.
- **Upgrading a World to v5**: a `Resolved hook resume` row in the
optional-surface table for `capabilities.resolvedHookResume`.

I also checked #5156 and #4761, which shipped their own docs updates. No
stale references to `start({ hook })` being refused on spec 9 remain.

### Docs Preview

- [resumeHook: Reuse a resolved
hook](https://workflow-docs-git-workflow-docs-cleanup-mon-2-nov.vercel.sh/docs/api-reference/workflow-api/resume-hook#reuse-a-resolved-hook)
-
[getHookByToken](https://workflow-docs-git-workflow-docs-cleanup-mon-2-nov.vercel.sh/docs/api-reference/workflow-api/get-hook-by-token)
- [What's new in v5: Also new in
5.0](https://workflow-docs-git-workflow-docs-cleanup-mon-2-nov.vercel.sh/docs/whats-new#also-new-in-50)
- [Upgrading a World to v5: New optional
surface](https://workflow-docs-git-workflow-docs-cleanup-mon-2-nov.vercel.sh/worlds/upgrading-to-v5#new-optional-surface)

### PR Checklist - Required to merge

- [x] 🔒 DCO sign-off included on every commit

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
Co-authored-by: Pranay Prakash <1797812+pranaygp@users.noreply.github.com>

This branch was successfully deployed

1 active deployment
Preview – workflow-docs — 4c54f84f Deployed Oct 30, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants