Migrate from Railway to Vercel: fold API and worker into the web app, replace BullMQ with durable workflows - #3
Merged
Merged
Conversation
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.
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (119)
📝 WalkthroughWalkthroughThe application moves API, authentication, webhook handling, and background processing into ChangesVercel migration foundation
Authentication and API routes
Webhook and workflow processing
Validation and documentation
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
Possibly related PRs
✨ Finishing Touches📝 Generate docstrings
Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Migrates Overlap off Railway onto Vercel plus a hosted Postgres. Three deployed services become one:
apps/api(Fastify) andapps/worker(six always-on BullMQ workers) fold intoapps/webas 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: 5000and 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(...)thenawait 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.tsreads the workflow runtime's own durable event log and assertssyncCompleted < detectCreated. Review verified it non-vacuously by making the steps concurrent viaPromise.alland confirming the assertion fires.Bugs fixed in transit
branch-sync.tscaught a GitHub failure, setchangedFiles = [], then unconditionally deleted everybranchFilesrow for the branch. A transient 500 wiped the file index, after which detection marked genuine overlapsresolvedand they vanished from the UI with no error anywhere.github-feedback.ts, same shape.Date.now()suffix added specifically to defeat dedup.Idempotency
Deduplication moves from BullMQ's
jobIdto the unique constraint onwebhook_events.deliveryId, which is durable in Postgres rather than expiring out of Redis on a TTL. AdispatchedAtcolumn 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 testspnpm test:integration- 5/5 workflow integration testspnpm --filter @overlap/web build- clean, 16 steps and 4 workflows registeredAlso fixed a pre-existing repo-wide lint failure:
eslint.config.jsimportedtypescript-eslint, which was absent frompackage.jsonsince the first commit, sopnpm linthad never passed for anyone.Review findings addressed
Two majors from final review, both genuine consequences of the serverless move:
maxDuration: 60. Note it goes invite.config.ts, notvercel.json- Nitro's vercel preset generates.vc-config.jsonitself and ignores afunctionsglob invercel.json, confirmed empirically by building with one and finding the setting absent.postgres-jspool defaults were wrong for serverless.max: 10with noidle_timeoutmeant 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. Nowmax: 3,idle_timeout: 10.pruneStaleBrancheswas 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 -
requireRepoAccessis 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
processWebhookrenders 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:
vercel.jsonmay never be read if the project's Root Directory isapps/web, in which case both cron jobs never fire and pruning silently stops./api/webhooks/githuband/api/auth/github/callback. A host-only flip 404s every delivery and breaks OAuth with aredirect_urimismatch.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/dbwas never provider-specific. Note that Neon distinguishes pooled from direct connections by hostname (a-poolersuffix), 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_settingsneeds manual carry-over.Summary by CodeRabbit
New Features
Bug Fixes
Tests