Skip to content

Migrate from Railway to Vercel: fold API and worker into the web app, replace BullMQ with durable workflows - #3

Merged
crodris merged 35 commits into
mainfrom
feat/vercel-migration
Aug 13, 2026
Merged

Migrate from Railway to Vercel: fold API and worker into the web app, replace BullMQ with durable workflows#3
crodris merged 35 commits into
mainfrom
feat/vercel-migration

Conversation

@crodris

@crodris crodris commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Migrates Overlap off Railway onto Vercel plus a hosted Postgres. Three deployed services become one: apps/api (Fastify) and apps/worker (six always-on BullMQ workers) fold into apps/web as TanStack Start server routes and Workflow DevKit durable workflows. Redis and BullMQ are deleted entirely.

Why

Railway bills per second for running services, and the BullMQ worker is always-on by design, so a low-traffic app pays continuously for an idle process. Redis had exactly one consumer (BullMQ) and falls out with it.

The correctness win

The old job graph ran branch sync and overlap detection as two independent BullMQ queues with no ordering between them, papered over with delay: 5000 and the comment "Small delay to ensure sync completes first". If sync took longer than five seconds, detection read a stale file list and computed wrong overlaps. If it finished in 200ms, the pipeline stalled for 4.8 seconds for nothing.

await syncBranchFiles(...) then await detectOverlaps(...) inside a durable workflow is a real happens-before edge. The delay is gone.

This is gated, not just asserted: apps/web/test/integration/process-webhook.integration.test.ts reads the workflow runtime's own durable event log and asserts syncCompleted < detectCreated. Review verified it non-vacuously by making the steps concurrent via Promise.all and confirming the assertion fires.

Bugs fixed in transit

  • Silent data loss. branch-sync.ts caught a GitHub failure, set changedFiles = [], then unconditionally deleted every branchFiles row for the branch. A transient 500 wiped the file index, after which detection marked genuine overlaps resolved and they vanished from the UI with no error anywhere.
  • Swallowed check-run failures in github-feedback.ts, same shape.
  • Branch-deletion cleanup had gone missing during the port, which would have left deleted branches generating phantom overlaps and notifications for up to 14 days.
  • Three BullMQ deduplication workarounds deleted rather than ported, including a Date.now() suffix added specifically to defeat dedup.

Idempotency

Deduplication moves from BullMQ's jobId to the unique constraint on webhook_events.deliveryId, which is durable in Postgres rather than expiring out of Redis on a TTL. A dispatchedAt column was added so a redelivery can tell "crashed before dispatch" from "still running", making GitHub's Redeliver button curative rather than a silent no-op.

Verification

  • npx turbo typecheck lint test --force - 9/9 tasks, 64 tests
  • pnpm test:integration - 5/5 workflow integration tests
  • pnpm --filter @overlap/web build - clean, 16 steps and 4 workflows registered

Also fixed a pre-existing repo-wide lint failure: eslint.config.js imported typescript-eslint, which was absent from package.json since the first commit, so pnpm lint had never passed for anyone.

Review findings addressed

Two majors from final review, both genuine consequences of the serverless move:

  • No function duration was configured. Now maxDuration: 60. Note it goes in vite.config.ts, not vercel.json - Nitro's vercel preset generates .vc-config.json itself and ignores a functions glob in vercel.json, confirmed empirically by building with one and finding the setting absent.
  • postgres-js pool defaults were wrong for serverless. max: 10 with no idle_timeout meant warm instances never released Supavisor connections; ~20 concurrent instances exhausted a 200-connection pooler and the webhook route would 500 into GitHub's retry budget. Now max: 3, idle_timeout: 10.

pruneStaleBranches was also a single step containing a whole-fleet loop, contradicting the cron route's own comment; it now fans out to one durable step per repository.

Known limitation, recorded not fixed

Repository authorization scopes by installation, not by the user's per-repository GitHub access. An org member who can reach one repo in an installation can read all of them, including full patch content via /api/repositories/$id/diffs, which uses the installation token.

This is pre-existing and ships today on Railway - requireRepoAccess is a faithful port. It is called out because the design spec previously listed repository authorization as "verified clean", which was true at the route layer and wrong at the tenant boundary. That entry has been corrected so the next reviewer does not skip the area. Fixing it properly needs a per-user repo grant table and is a separate change.

Also accepted deliberately: check-run idempotency is narrowed rather than closed, redelivery dedup has a one-database-write-wide crash window (the SDK exposes no start-time idempotency key), and processWebhook renders as an empty workflow graph because the static graph builder emits an empty DAG for any workflow containing try/catch.

Not in this PR

The cutover itself. Creating the database and Vercel projects, setting production secrets, and repointing the GitHub App are operational steps on live accounts, documented in docs/superpowers/specs/2026-08-12-vercel-cutover-runbook.md.

Three of those fail silently if missed, so they are worth reading before deploying:

  1. vercel.json may never be read if the project's Root Directory is apps/web, in which case both cron jobs never fire and pruning silently stops.
  2. The GitHub App URLs changed path, not just host: /api/webhooks/github and /api/auth/github/callback. A host-only flip 404s every delivery and breaks OAuth with a redirect_uri mismatch.

The database is Neon, not Supabase - decided during this PR. Supabase's compute credit was already consumed by another project, so a second one was $10/month of new spend, which would have left the migration $5/month worse off than the Railway worker it removes. No code changed; packages/db was never provider-specific. Note that Neon distinguishes pooled from direct connections by hostname (a -pooler suffix), not by port as Supabase does. This also eliminates a security step outright: Neon has no public PostgREST surface, so there is no Data API to close.

No data is migrated. Almost every table is a rebuildable cache of GitHub state, so the pipeline repopulates itself - which also makes the rebuild the acceptance test for the migration. Only repository_settings needs manual carry-over.

Summary by CodeRabbit

  • New Features

    • Added GitHub sign-in, session management, repository access, branch, overlap, settings, and diff APIs.
    • Added GitHub webhook processing with duplicate-event protection and durable background workflows.
    • Added push notification subscription management and scheduled maintenance tasks.
    • Added health monitoring and protected cron endpoints.
    • Added Vercel deployment and scheduled job configuration.
  • Bug Fixes

    • Improved GitHub error handling, retry behavior, webhook validation, and check-run updates.
  • Tests

    • Expanded unit and integration coverage for authentication, workflows, webhooks, database operations, and notifications.

crodris added 30 commits August 12, 2026 17:01
Design for moving Overlap off Railway onto Vercel + Supabase:
- collapse apps/web, apps/api, apps/worker into one Vercel project
- replace always-on BullMQ worker with Workflow DevKit durable workflows
- drop Redis entirely (BullMQ was its only consumer)
- replace proactive rate limiters with reactive Retry-After backoff
- fix the 5s delay race between branch sync and overlap detection
- fix silent branch-file wipe on transient GitHub failures
Design-level security review of the migration plan. Most findings are
safety properties Railway provided implicitly that Vercel/Supabase does not:

- S1: Supabase exposes PostgREST publicly; restored tables have no RLS
- S2: make webhook HMAC verification an explicit precondition
- S3: dropping rate limits turns DoS into billed invocations
- S4: JWT must not replace the session model, only the signature
- S5: rotate SESSION_SECRET at cutover
- S6: timing-safe CRON_SECRET comparison
- S7: keep oauth_state cookie integrity-protected
- S8: keep webhook payloads out of workflow arguments
- S9: /health/ready breaks unless the Redis check is removed

Verified clean: repository authorization (requireRepoAccess), and CSRF
exposure after the same-origin fold-in.
Production is 1 user, 1 repository, 3 branches; 51 webhook_events rows
are a 7-day audit log. Nearly every table is a materialized cache of
GitHub state that syncRepository/syncBranchFiles/detectOverlaps rebuild
on demand.

Migrate by running db:migrate against an empty Supabase project and
letting the pipeline repopulate:
- shrinks S1 from a live data exposure to a hardening task
- makes the rebuild itself the acceptance test for the new workflows
- avoids carrying forward corruption from the branch-sync file-wipe bug

Only repository_settings needs manual carry-over, and only if changed
from defaults.
15 tasks across 4 phases, test-first. Installs workflow@4.8.2 so the
plan is written against the bundled docs rather than recollection.

Two corrections to the spec discovered while reading those docs:
- step memoization does not protect a step that succeeds at GitHub then
  fails before returning, so check run posting needs explicit
  idempotency (Task 9)
- packages/github withRetry double-retries under WDK and hides the
  rate-limit signal, so it is removed
Retarget the web app's Nitro build from node_server to the vercel
preset so it produces .vercel/output. Enable the workflow/vite plugin
(ordered before tanstackStart) and the workflow TypeScript plugin so
"use workflow" and "use step" directives compile ahead of later tasks.
Also drops the /auth and /api dev-server proxy and routeRules, since a
later task folds the API into this app as server routes.
- Rewrite queryClient with prepare: false to work with Supabase Supavisor
  transaction pooler (port 6543), which cannot use prepared statements
- Update migrationClient to use DIRECT_URL (port 5432) to bypass pooler
  constraints, since DDL statements require a direct connection
- Update drizzle.config.ts to prefer DIRECT_URL for migrations, with
  fallback to DATABASE_URL for backward compatibility
- Update .env.example with new Supabase connection variables and remove
  unused REDIS_URL
Add @overlap/db and drizzle-orm dependencies to @overlap/web to support
the new auth helper. This allows server routes to authenticate requests
using the session cookie and perform per-request user lookups for
immediate session revocation.

Also updated vitest config to provide DATABASE_URL for tests.
Ports apps/api/src/routes/health.ts and auth.ts into TanStack Start
server routes ahead of the Fastify API's removal. The health route
drops the Redis check since Redis is being deleted in this migration.
The oauth_state cookie is now a signed JWT (verified with jwtVerify
pinned to HS256) rather than a bare value, preserving CSRF integrity
protection without @fastify/cookie's signed-cookie support.

Also includes the regenerated routeTree.gen.ts so typecheck passes
without requiring a prior build step.
Ports apps/api/src/routes/repositories.ts (8 route handlers plus the
requireRepoAccess/getUserInstallationIds helpers) and
apps/api/src/routes/push.ts into TanStack Start server routes ahead of
the Fastify API's removal.

requireRepoAccess is carried over with its access-checking logic
unchanged, only its signature and failure path adapted to throw a
Response instead of using Fastify's reply. Every :id route calls it
before touching data, matching the original.

push.ts keeps ALLOWED_PUSH_HOSTS and isAllowedPushEndpoint byte-for-byte
identical, and adds the per-user subscription cap (20) required by spec
S3 as an additional check alongside the existing host allowlist.

The DEV-only test-notify route ported the auth/access-check/lookup
logic faithfully, but its BullMQ enqueue has no equivalent yet: push
dispatch becomes the sendPush workflow step in a later migration task.
It now returns 501 rather than silently doing nothing or duplicating
that task's work early.

Also adds @overlap/github as a web dependency (needed by the diffs
handler's getGitHubClient call, not yet present in apps/web), and
includes the regenerated routeTree.gen.ts so typecheck passes without
requiring a prior build step.
Fixes two swallowed-error paths carried over from the BullMQ processors:
branch-sync no longer wipes a branch file index on transient GitHub
failure, and check-run creation failures no longer pass silently.
The previous "finite and non-negative" guard still let unusable values
reach RetryableError, reproducing the crash-inside-the-catch it was
meant to prevent: headers >= 1e21 stringified to exponential notation
that ms cannot parse and threw a bare Error out of the classifier,
values above ~8.64e12 seconds overflowed the Date range, and Number()
happily accepted hex, exponent, signed and fractional forms that are
not seconds counts.

Accept only a bare run of digits, capped at one hour, and return a
RetryableError when the thrown value is not an object at all.
- Add updateCheckRun to the GitHub client alongside createCheckRun.
- Remove the withRetry helper and unwrap every call site: the Workflow
  runtime now owns retry policy, and an inner retry loop was hiding the
  rate-limit signal classifyGitHubError needs to size RetryableError
  correctly. RateLimitError and its now-unused helpers (isRateLimitError,
  isSecondaryRateLimit, isTransientError, getRateLimitRetryAfter, sleep)
  go with it since they existed only to support that loop.
- postCheckRun now looks up the pr_alerts row before calling GitHub. An
  existing checkRunId takes the update path; otherwise it creates a check
  run and records the id immediately, so a retry after a successful
  GitHub call finds it and updates instead of duplicating.
- Add branching coverage for postCheckRun via a fake GitHub client and a
  mocked db.
Replaces the 5s delay between branch sync and overlap detection with a
real happens-before edge.

processWebhook wraps its body in try/catch so markEventProcessed runs on
both the success and the failure path; the BullMQ processor got that
bookkeeping structurally from its own try/catch and the workflow author
now owns it.

detectOverlaps returns the same open-PR list on every notification, so
the check run fan-out unions the pull request ids and posts each check
run once instead of once per (overlap, PR) pair. postCheckRun already
reports every active overlap for the branch in a single check run, so
the extra calls were redundant GitHub API traffic.
The route returned 501 because its BullMQ pushNotification enqueue had
no replacement. testNotifyWorkflow wraps the sendPush step, so the
endpoint starts a durable run again. Auth, repo access, the overlap
lookup and the 404 path are unchanged.
Signature verification of the raw request body precedes every database
write and every workflow start (Spec S2). Deduplication moves from
BullMQ's jobId to the webhook_events.deliveryId unique constraint,
which is durable in Postgres rather than expiring out of Redis on a
TTL: onConflictDoNothing().returning() returns no row on a GitHub
redelivery, and the handler returns 200 without starting a second
workflow run.

The route stays a thin wrapper; handleWebhook takes the workflow
start function as an injected dependency so the ordering is testable
without a running workflow runtime. Branch-deletion cleanup, handled
inline and synchronously in the old Fastify route, already lives in
the Task 10 workflow's upsertBranch step and is intentionally not
duplicated here.
…or webhooks

Two gaps from review of the webhook route port:

1. upsertBranch skipped branch-deletion pushes instead of retiring the
   branch, a stale carry-over from the old Fastify route that used to
   do this inline. Deleted branches kept generating overlaps and
   check-run annotations for up to the 14-day pruning window. Now
   deletes branch_files, resolves affected overlaps to 'resolved', and
   deletes the branches row, mirroring pruneStaleBranches.

2. A redelivery of an already-stored deliveryId always short-circuited
   to 200 without starting a workflow, even when the original
   deps.start() had crashed or the process died mid-request and left
   the row with processedAt still null. Redelivery is now curative:
   an unprocessed row re-dispatches the workflow, a processed one
   stays a no-op.

Also: an empty x-github-delivery header now returns 400 instead of
deduping every such delivery onto the empty string forever, and a
validly-signed non-object JSON body (e.g. `null`) returns 400 instead
of throwing on `payload.repository`.
The previous fix (conflict on webhook_events.deliveryId -> re-check
processedAt) closed the "crashed before starting" gap but opened a new
one: a GitHub Redeliver that arrives while the original processWebhook
run is still executing also had processedAt null, so it would start a
second run for the same delivery, the exact duplicate-check-run and
duplicate-push-notification class of bug the dedup exists to prevent.

Adds a dispatchedAt column, set only after deps.start() has actually
returned. Redelivery now restarts the workflow in exactly two states:
dispatchedAt still null (no run was ever created), or error set with
processedAt null (the run terminally failed, which Redeliver should be
able to recover). An in-flight run (dispatchedAt set, no error, not
processed) or a finished one (processedAt set) is left alone.

Generated via drizzle-kit generate (packages/db/drizzle/0004_*.sql).
While generating it, found that migration 0003_user_installations.sql
was hand-authored in an earlier commit without ever running generate,
so drizzle-kit had no snapshot for it and was about to re-emit its
CREATE TABLE statements into 0004 - which would fail migrating a fresh
database, since 0003 already creates that table. Backfilled the
missing packages/db/drizzle/meta/0003_snapshot.json by generating it
from the schema state 0003 already represents, so 0004 now contains
only the actual diff (the new column) and the snapshot chain (0000 ->
0001 -> 0002 -> 0003 -> 0004) is consistent again. No SQL file's
content changed; only the missing bookkeeping artifact was restored.
Adds timing-safe CRON_SECRET authorization and two Vercel Cron routes
that start the prune-branches (every 6 hours) and cleanup-events
(daily) workflows, matching the schedules previously configured via
BullMQ's upsertJobScheduler in apps/api/src/scheduler.ts.
Adds a second Vitest project, driven by the `workflow()` plugin from
`@workflow/vitest`, that runs `processWebhook` through the real workflow
runtime. Three behaviours that no unit test can reach - a "use workflow"
function throws when invoked directly - are now covered:

  - Sync happens-before detect. The BullMQ system ran branch sync and
    overlap detection as independent jobs and hoped `delay: 5000` kept
    them in order. The test asserts the edge from the runtime's own event
    log, and asserts detection consumed the index the sync wrote: the
    pushed branch is seeded with an empty file index, so the overlap it
    finds can only have come from the sync's output.
  - Failure bookkeeping. A failing delivery records the error on its
    webhook_events row via markEventProcessed and still fails the run,
    covered for a first-step failure and for one raised several steps in.
  - Check run fan-out. N overlaps across M pull requests post M check
    runs, not N*M.

No database is available, so the tests bring their own. The step bundle
that @workflow/vitest generates is loaded by Node, not by Vitest, which
is why vi.mock() and resolve.alias cannot reach it - but it also leaves
bare package specifiers external, so a package written into
.workflow-vitest/node_modules shadows the workspace one for that bundle
alone. The @overlap/db shim installed there keeps the real schema,
relations and drizzle and swaps only the connection, pointing at PGlite
with the project's own migrations applied. The steps therefore run their
production SQL, raw `sql` templates and relational loads included,
against the real schema. Only getGitHubClient is faked.

@electric-sql/pglite is added to packages/db as well: it is an optional
peer of drizzle-orm, and without it on both sides pnpm resolves two
copies, one building the schema and another querying it.

patch-step-bundle.ts works around a codegen bug in @workflow/vitest
4.0.18, which emits a JSON import with no import attribute and so
produces a bundle no current Node can load. Delete it once the SDK
emits the attribute.

No production code changed.
Adding @electric-sql/pglite to apps/web and packages/db broke the
repo-wide typecheck: it is an optional peer of drizzle-orm, and pnpm
keys a package instance by its resolved peer set, so those two packages
got a drizzle-orm variant that apps/api and apps/worker did not. The
schema was built by one copy and queried by another, and every table
type mismatched across the boundary - 137 errors in @overlap/worker.

`pnpm --filter @overlap/web typecheck` passed throughout, which is why
it was missed; a filtered gate cannot see damage that only exists at the
boundary between packages.

Moving pglite to the workspace root fixes it. The root is not a
dependent of drizzle-orm, and pnpm's resolve-peers-from-workspace-root
satisfies the optional peer identically for every workspace project, so
apps/web, apps/api, apps/worker and packages/db now share one instance.
A clean install from the lockfile produces exactly one drizzle-orm
directory where there were two. An override cannot merge two variants of
the same version, and packageExtensions cannot delete a declared peer,
so this is the only complete fix that does not spread a test-only
dependency across every production manifest. db-shim.ts documents the
constraint and the error text it produces if reintroduced.

patch-step-bundle.ts now throws instead of returning silently when the
generated bundle does not look the way it expects - naming the file, the
pattern, the installed @workflow/vitest version and what to do next -
so an SDK upgrade fails immediately and readably rather than restoring
the swallowed error and 60s timeout it exists to prevent. Both guard
paths verified. The already-patched path still returns quietly, which is
required so concurrent workers do not race each other into a failure.

No production code or test assertions changed. The sync-then-detect
mutation still fails the suite exactly as before.
Root `pnpm test` is `turbo test`, which does not include test:integration,
and there is no CI. The Turborepo task and the package-level script both
existed, but nothing invoked them by default, so the only runtime proof
that the sync-then-detect ordering holds was reachable only by someone
who already knew it was there. A regression would have passed every
command anyone actually runs.

Adds a root test:integration script alongside test, matching the
surrounding `turbo <task>` formatting.

`pnpm test` is left alone on purpose: a 0.6s unit suite and a 6.3s
workflow suite are worth keeping separate, and merging them would slow
the fast feedback loop six-fold for no extra coverage. The integration
suite just needed to be reachable by name.
apps/api and apps/worker are now served by apps/web. Every route and
processor has been ported to apps/web/src as part of the Vercel
migration, so the old Fastify API and BullMQ worker are dead code.

Redis had exactly one consumer (BullMQ) and is deleted with it, along
with railway.json and apps/web/Dockerfile. QUEUE_NAMES and
RATE_LIMITS are removed from packages/shared since their only
consumers were the deleted apps.
- Add the missing typescript-eslint dependency so `pnpm lint` (broken
  since the first commit) actually runs, then fix the two real
  unused-import errors it surfaced. Also mark the root package as
  type: module to silence eslint's CJS/ESM warning on eslint.config.js.
- Replace every em dash with a plain hyphen in shipped source, per the
  repo's authoring rule.
- Replace the deprecated `json` helper from '@tanstack/react-start'
  with the standard `Response.json` across all server routes that
  used it.
- Document CRON_SECRET in .env.example.
- Drop the stale "start" script from apps/web/package.json; the
  Vercel preset emits to .vercel/output and Vercel never invokes it.
- Fix markEventProcessed to branch on `error !== undefined` instead of
  truthiness, so an empty-string error message is no longer treated
  as success.
- Delete apps/web/railway.json and apps/web/railway.toml. Both were
  still tracked and pointed at files already deleted in this branch
  (apps/web/Dockerfile, the `pnpm start` script), left over because
  the brief's Step 2 only named the root railway.json.
- Replace the em dashes in apps/web/public/sw.js and
  apps/web/public/theme-init.js with plain hyphens. These are served
  directly (registered as the service worker and loaded via a
  <script> tag) and were missed because the earlier em-dash grep was
  scoped to apps/web/src and packages/, excluding apps/web/public/.
- Remove API_URL and VITE_API_URL from .env.example; both point at
  the deleted apps/api backend on port 3001 and have no consumers.
  Repoint VAPID_SUBJECT's placeholder off the Railway domain default.
  Confirmed VITE_GITHUB_APP_SLUG and VITE_VAPID_PUBLIC_KEY are still
  read (protected-route.tsx, use-push-notifications.ts) and left them.
- Add a regression test for the B6 fix: markEventProcessed('') must
  take the error path, not the success path.
… fix wave

Finding 1 (critical): the Fastify->TanStack Start migration dropped the
/api proxy but nobody updated the client callers, so /auth/github,
/auth/me, /auth/logout, /api/push/subscribe and /api/push/unsubscribe
all 404'd - nobody could sign in. Point them at the routes that actually
exist: /api/auth/github, /api/auth/me, /api/auth/logout, and a single
/api/push handling both POST and DELETE.

Finding 2: document that the GitHub App cutover changes webhook and
OAuth callback PATHS, not just the host, since a stale registration
produces a redirect_uri mismatch.

Finding 3: delete the dead syncRepositoryWorkflow (never started, only
inflating the workflow count), delete the stale API_ROUTES constant
that still encoded the pre-migration paths, order the test-notify
route's production check before its auth check so it 404s instead of
401 in prod, and ignore the webhook handler/test files from file-based
route generation so they stop warning on every build.
Consolidates the operational steps and risks found during implementation
and review into one document, since tasks 14 and 15 of the plan run
against live accounts and were not automated.

Captures the failure modes that are silent if missed: the Supabase Data
API being open by default, vercel.json possibly not being read if Root
Directory is apps/web, and the GitHub App URLs changing path rather than
just host.
The repo has no aggregate verify script and no CI, so the gate is
composed explicitly. Saved so later ship runs do not re-ask.
…, fan out branch pruning, correct security doc

Stage 1 code review fixes for the Vercel migration:

- Set an explicit 60s maxDuration on the single __server Vercel Function via
  the Nitro vercel preset config (apps/web/vite.config.ts). A functions block
  in root vercel.json does not reach a Nitro-generated Build Output API
  function - confirmed by rebuilding with one present and finding it absent
  from the built .vc-config.json - so vercel.json is left untouched and the
  setting lives where it actually takes effect.
- Give postgres-js a serverless-appropriate pool (max: 3, idle_timeout: 10)
  so warm instances stop holding up to 10 idle Supavisor connections each,
  which was on track to exhaust the transaction pooler under normal fleet
  size.
- Restructure branch pruning so each repository is its own durable step
  (getActiveRepositoryIds + pruneRepositoryBranches), matching the pattern
  processWebhook already uses for syncRepository, instead of looping over
  every active repository inside one step invocation.
- Rewrite the design doc's "Verified clean" repository-authorization entry:
  it was true at the route layer and wrong at the tenant boundary
  (installation-scoped access, not per-user), and is now documented with the
  failure scenario and file references instead of a blanket "no IDOR found".
- Validate overlapId as a UUID in the overlaps and test-notify routes so a
  malformed id 404s instead of a raw Postgres 22P02 surfacing as a 500.
- Clear the error column when markEventProcessed records success, so a
  cured GitHub Redeliver no longer leaves both error and processedAt set.
- Handle GitHub 403/429 explicitly in the diffs route (which is not a step
  and gets no retry) instead of falling through to an unhandled 500.
Supabase's monthly compute credit was already consumed by another
project, making a second project $10/month of new spend. That would have
left this migration $5/month worse off than the idle Railway worker it
was meant to remove. Neon's free tier covers this workload.

No code changes: packages/db takes a pooled Postgres connection string
and nothing in the branch was provider-specific.

- Pooled vs direct now differ by HOSTNAME (-pooler suffix), not by port.
  The Supabase shape distinguishes by port, so this is the detail most
  likely to be got wrong at cutover.
- Security item S1 (closing Supabase's public Data API) no longer
  applies. Neon exposes no PostgREST surface, so that risk is eliminated
  rather than mitigated. S1 is retained in the spec as a record.
- Kept prepare:false. Neon's PgBouncer does support protocol-level
  prepared statements, so it is conservative rather than required, but
  the failure mode if wrong is intermittent rather than clean.
… to 120, pin prune test scoping

syncRepository looped one UPDATE-or-INSERT per branch plus one UPDATE
per missing branch, which on a repository with a few thousand branches
is thousands of sequential DB round trips inside a single workflow
step invocation - easily exceeding maxDuration and causing the step to
retry into an identical timeout forever. Both loops are now set-based
statements (bulk insert with onConflictDoUpdate/setWhere, and a single
inArray update), verified against a real PGlite database to match the
old loop's behavior exactly, including its no-write-on-unchanged-SHA
quirk. added/updated counters stay exact since they're computed from
data already fetched, not from the batched statement.

The vite.config.ts maxDuration comment previously reasoned only from
the request path, contradicting steps.ts's own note that a workflow
step is an invocation of the same function. Rewrote it to name
syncRepository's GitHub pagination as the dominant remaining cost
after batching, and raised maxDuration 60 -> 120 for headroom against
larger repositories.

prune-repository-branches.test.ts asserted expect.anything() on the
staleBranches where-clause, so deleting its repositoryId scoping term
would not have failed any test. Both flagged tests now render the
captured predicate to real SQL + params via PgDialect and compare
against an independently-built expected predicate; verified by
temporarily removing the scoping term and confirming both tests fail.

Also corrects the design doc's stale pruneStaleBranches references and
its now-false claim that pruning must not be bounded by a single
function invocation.
…ed plan doc

Final review round on feat/vercel-migration, five findings:

- packages/db/drizzle.config.ts: replace the stale Supabase port-based
  pooled/direct rule with Neon's hostname rule, mirroring client.ts.
- apps/web/src/workflows/steps.ts: chunk syncRepository's bulk branch
  insert at 5000 rows (BRANCH_INSERT_CHUNK_SIZE) to stay under the
  13106-row Postgres Bind-message parameter ceiling; past that ceiling
  every retry failed identically and the repository never finished
  syncing. The inArray UPDATE for missing branches is left unchunked -
  its ~65533-id ceiling is far above any practical repository size.
  Adds apps/web/src/workflows/__tests__/sync-repository.test.ts pinning
  that a branch list larger than one chunk produces multiple insert
  calls with no rows lost.
- apps/web/vite.config.ts: correct the maxDuration comment to state
  that only syncRepository was measured, and name detectOverlaps,
  syncInstallation, and applyInstallationRepositories as unaudited
  steps sharing the same budget (not batched this round, per scope).
- docs/superpowers/specs/2026-08-12-vercel-migration-design.md: add an
  explicit cross-reference to the Neon amendment on the remaining
  Supavisor/6543 line so its coverage is deliberate, not incidental.
- docs/superpowers/plans/2026-08-12-vercel-migration.md: stamp the plan
  as superseded and fully executed on this branch, so an agent handed
  the document does not re-run it and revert the Neon switch.
.ship-fix-report.md is a scratch transcript of the ship run's review
rounds. It was committed by accident in bed6df5 and would otherwise
land on main. The pipeline profile in .ship/config.md stays tracked -
that one is a team-facing decision, this one is not.
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2cb8ab18-d593-4e1d-bb49-d00728f477e3

📥 Commits

Reviewing files that changed from the base of the PR and between 0e9167d and 8ea83ae.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (119)
  • .env.example
  • .gitignore
  • .ship/config.md
  • apps/api/Dockerfile
  • apps/api/package.json
  • apps/api/railway.json
  • apps/api/railway.toml
  • apps/api/src/index.ts
  • apps/api/src/plugins/auth.ts
  • apps/api/src/queues/index.ts
  • apps/api/src/routes/auth.ts
  • apps/api/src/routes/health.ts
  • apps/api/src/routes/push.ts
  • apps/api/src/routes/repositories.ts
  • apps/api/src/routes/webhooks.ts
  • apps/api/src/scheduler.ts
  • apps/api/tsconfig.json
  • apps/api/tsup.config.ts
  • apps/web/.gitignore
  • apps/web/Dockerfile
  • apps/web/package.json
  • apps/web/public/sw.js
  • apps/web/public/theme-init.js
  • apps/web/railway.json
  • apps/web/railway.toml
  • apps/web/src/components/protected-route.tsx
  • apps/web/src/lib/__tests__/auth.test.ts
  • apps/web/src/lib/__tests__/cron-auth.test.ts
  • apps/web/src/lib/__tests__/session.test.ts
  • apps/web/src/lib/__tests__/smoke.test.ts
  • apps/web/src/lib/api.ts
  • apps/web/src/lib/auth.ts
  • apps/web/src/lib/cron-auth.ts
  • apps/web/src/lib/github-oauth.ts
  • apps/web/src/lib/repo-access.ts
  • apps/web/src/lib/session.ts
  • apps/web/src/routeTree.gen.ts
  • apps/web/src/routes/__root.tsx
  • apps/web/src/routes/api/__tests__/repositories.overlaps.overlap-id.test.ts
  • apps/web/src/routes/api/auth/github.callback.ts
  • apps/web/src/routes/api/auth/github.ts
  • apps/web/src/routes/api/auth/logout.ts
  • apps/web/src/routes/api/auth/me.ts
  • apps/web/src/routes/api/cron/cleanup-events.ts
  • apps/web/src/routes/api/cron/prune-branches.ts
  • apps/web/src/routes/api/health.ts
  • apps/web/src/routes/api/push.ts
  • apps/web/src/routes/api/repositories.$id.branches.ts
  • apps/web/src/routes/api/repositories.$id.diffs.ts
  • apps/web/src/routes/api/repositories.$id.overlaps.$overlapId.test-notify.ts
  • apps/web/src/routes/api/repositories.$id.overlaps.$overlapId.ts
  • apps/web/src/routes/api/repositories.$id.overlaps.ts
  • apps/web/src/routes/api/repositories.$id.settings.ts
  • apps/web/src/routes/api/repositories.$id.ts
  • apps/web/src/routes/api/repositories.ts
  • apps/web/src/routes/api/webhooks/__tests__/verify-order.test.ts
  • apps/web/src/routes/api/webhooks/github-handler.ts
  • apps/web/src/routes/api/webhooks/github.ts
  • apps/web/src/routes/index.tsx
  • apps/web/src/routes/login.tsx
  • apps/web/src/routes/repositories.tsx
  • apps/web/src/routes/repositories_.$repoId.tsx
  • apps/web/src/workflows/__tests__/errors.test.ts
  • apps/web/src/workflows/__tests__/mark-event-processed.test.ts
  • apps/web/src/workflows/__tests__/post-check-run.test.ts
  • apps/web/src/workflows/__tests__/prune-repository-branches.test.ts
  • apps/web/src/workflows/__tests__/sync-repository.test.ts
  • apps/web/src/workflows/__tests__/upsert-branch.test.ts
  • apps/web/src/workflows/errors.ts
  • apps/web/src/workflows/maintenance.ts
  • apps/web/src/workflows/process-webhook.ts
  • apps/web/src/workflows/steps.ts
  • apps/web/src/workflows/test-notify.ts
  • apps/web/test/integration/build-shims.ts
  • apps/web/test/integration/db-shim.ts
  • apps/web/test/integration/fixtures.ts
  • apps/web/test/integration/github-shim.ts
  • apps/web/test/integration/github-state.ts
  • apps/web/test/integration/harness.ts
  • apps/web/test/integration/patch-step-bundle.ts
  • apps/web/test/integration/process-webhook.integration.test.ts
  • apps/web/test/integration/run-events.ts
  • apps/web/test/integration/shared-shim.ts
  • apps/web/tsconfig.json
  • apps/web/vite.config.ts
  • apps/web/vitest.config.ts
  • apps/web/vitest.integration.config.ts
  • apps/worker/Dockerfile
  • apps/worker/package.json
  • apps/worker/railway.json
  • apps/worker/railway.toml
  • apps/worker/src/index.ts
  • apps/worker/src/processors/branch-sync.ts
  • apps/worker/src/processors/github-feedback.ts
  • apps/worker/src/processors/maintenance.ts
  • apps/worker/src/processors/overlap-detection.ts
  • apps/worker/src/processors/push-notification.ts
  • apps/worker/src/processors/webhook-events.ts
  • apps/worker/tsconfig.json
  • apps/worker/tsup.config.ts
  • docker-compose.yml
  • docs/superpowers/plans/2026-08-12-vercel-migration.md
  • docs/superpowers/specs/2026-08-12-vercel-cutover-runbook.md
  • docs/superpowers/specs/2026-08-12-vercel-migration-design.md
  • package.json
  • packages/db/drizzle.config.ts
  • packages/db/drizzle/0004_noisy_daimon_hellstrom.sql
  • packages/db/drizzle/meta/0003_snapshot.json
  • packages/db/drizzle/meta/0004_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/src/client.ts
  • packages/db/src/schema/index.ts
  • packages/github/src/client.ts
  • packages/github/src/index.ts
  • packages/github/src/webhooks.ts
  • packages/shared/src/constants/index.ts
  • railway.json
  • turbo.json
  • vercel.json

📝 Walkthrough

Walkthrough

The application moves API, authentication, webhook handling, and background processing into apps/web. It replaces Redis and BullMQ with durable workflows, adds pooled/direct Neon database configuration, and adds Vercel deployment and integration-test support.

Changes

Vercel migration foundation

Layer / File(s) Summary
Platform, database, and build foundation
.env.example, packages/db/*, apps/web/*, vercel.json, turbo.json, package.json
Adds pooled/direct database settings, schema support for webhook dispatch state, Vercel presets and cron jobs, workflow plugins, and test commands.
Legacy service removal
apps/api/*, apps/worker/*, docker-compose.yml, packages/shared/src/constants/index.ts
Removes the API and worker deployments, Redis configuration, BullMQ processors, and queue constants.

Authentication and API routes

Layer / File(s) Summary
Authentication and OAuth
apps/web/src/lib/*, apps/web/src/routes/api/auth/*
Adds JWT sessions, secure cookies, GitHub OAuth, installation synchronization, logout, and current-user handling.
Repository and operational APIs
apps/web/src/routes/api/repositories*, apps/web/src/routes/api/push.ts, apps/web/src/routes/api/health.ts, apps/web/src/routes/api/cron/*
Adds authenticated repository, overlap, diff, settings, push-subscription, health, and cron endpoints.
Route registration and client wiring
apps/web/src/routeTree.gen.ts, apps/web/src/lib/api.ts, apps/web/src/routes/login.tsx
Registers API routes and updates authentication and push requests to use the /api paths.

Webhook and workflow processing

Layer / File(s) Summary
Webhook validation and delivery state
apps/web/src/routes/api/webhooks/*, packages/db/src/schema/index.ts, packages/db/drizzle/*
Verifies signatures before parsing, deduplicates deliveries, tracks dispatch state, and starts workflows.
Workflow steps and orchestration
apps/web/src/workflows/*
Ports queue processors to durable steps for synchronization, overlap detection, check runs, notifications, cleanup, and maintenance.
GitHub client behavior
packages/github/src/client.ts, packages/github/src/index.ts
Removes client-side retries and adds check-run update support.

Validation and documentation

Layer / File(s) Summary
Unit and integration coverage
apps/web/src/**/__tests__/*, apps/web/test/integration/*
Adds coverage for authentication, webhook ordering, workflow sequencing, retries, idempotency, database behavior, and fake GitHub interactions.
Migration documentation
docs/superpowers/plans/*, docs/superpowers/specs/*
Documents the migration architecture, implementation plan, cutover steps, rollback process, and operational constraints.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant GitHub
  participant WebhookRoute
  participant Database
  participant Workflow
  participant GitHubClient
  GitHub->>WebhookRoute: POST signed webhook
  WebhookRoute->>WebhookRoute: Verify raw-body HMAC
  WebhookRoute->>Database: Insert or load delivery by ID
  WebhookRoute->>Workflow: Start processWebhook(deliveryId)
  Workflow->>Database: Synchronize branches and detect overlaps
  Workflow->>GitHubClient: Create or update check runs
  Workflow->>Database: Mark delivery processed
Loading

Possibly related PRs

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@crodris
crodris merged commit c162aa3 into main Aug 13, 2026
1 check was pending
@crodris
crodris deleted the feat/vercel-migration branch August 13, 2026 21:51
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.

1 participant