Skip to content

feat(inbound): Gmail inbound deployment — composition root, Vercel routes, PG queue, Supabase blob (HT-43) - #43

Merged
zaridan merged 4 commits into
mainfrom
feat/ht-43-provisioning-composition-root
Jul 15, 2026
Merged

feat(inbound): Gmail inbound deployment — composition root, Vercel routes, PG queue, Supabase blob (HT-43)#43
zaridan merged 4 commits into
mainfrom
feat/ht-43-provisioning-composition-root

Conversation

@zaridan

@zaridan zaridan commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

HT-43 — Deployment: Vercel route + cron + GCP/Pub-Sub runbook + Supabase

Turns the merged HT-34…HT-42 engine into a deployable inbound-mail app. Three commits, one operational unit:

  1. docs(deploy) — the runbook (specs/deploy/gmail-inbound-runbook.md): the one-time GCP/Supabase/Vercel operator steps + the env/endpoint contract + a post-deploy smoke checklist.
  2. feat(providers) — the durable PG job queue (src/providers/adapters/postgres-queue/ + migration 013 queue_jobs): createPostgresQueue(db) — durable INSERT enqueue with dedupe, FOR UPDATE SKIP LOCKED lease-based drain, exponential backoff, retained dead-letters, getStats. Chosen over Vercel Queues (beta); the invariant is durable enqueue commits before the webhook acks Pub/Sub (the webhook already does this). 12 PGlite tests.
  3. feat(deploy) — the composition root (this review's focus).

What the composition root wires

  • src/composition/config.ts — eager env-contract validation. Aggregates all problems into one boot error; never echoes a secret value (length/shape only).
  • src/composition/root.ts — the one place concrete adapters are constructed and injected: PostgresDb (Supabase 6543 pooler) → every store; createMailboxTokenStore(db, encKey) (refresh-token encryption at rest); the Gmail OAuth token service + outbound EmailSender (per-mailbox token resolved lazily by support address); the Gmail push verifier (JWKS source built once per instance); the PG queue; the connect/consent service; createInboxApi with gmailPush + gmailConnect present (absent-by-default on the engine — wired only here); and the two cron closures. Per-instance memoized.
  • src/composition/app.ts — unified handler: routes the two CRON_SECRET-guarded internal cron endpoints (queue drain, watch maintenance) and delegates everything else to the inbox API. Reuses authenticateRequest for the Bearer cron-secret check (auth before method → no method oracle).
  • src/providers/adapters/supabase-storage/BlobStore over Supabase Storage (private bucket, signed reads only, service_role server-only).
  • api/[...path].ts + vercel.json — a single catch-all Vercel Node-runtime function using the fetch Web Standard export (handles all methods; hands us a web Request directly, so no node:http bridge is needed — see reviewer notes). Crons: drain every minute, watch-maintenance daily 06:00 UTC.
  • scripts/migrate.ts — one-shot migration runner against DATABASE_URL.

Sacred invariants (verified)

  • Adapter boundary: engine core (src/api, src/mail, src/store) imports only provider interfaces (type-only) — the only runtime @supabase/* import is the adapter itself; concretes are wired solely at the composition root.
  • Encryption at rest: the decoded HELPTHREAD_TOKEN_ENC_KEY is threaded to the token store and nowhere else.
  • No secret logged: config errors name variables, not values; the app/entry log only labels + engine errors (documented secret-free).
  • Durable-enqueue-before-ack and mail semantics: unchanged — this PR wires existing, fixture-proven modules.

Not in this PR (operator / HT-44)

Real provisioning + the live Google consent are the operator's job — this PR is the turnkey code + runbook. Still pending: the OAuth client (needs the deployed PUBLIC_BASE_URL), the Pub/Sub push subscription, a dedicated Supabase project, and the real mailbox connect (HT-44).

Post-deploy smoke checklist (runbook Part F)

  • GET /api/v1/conversations with the Bearer token → 200; wrong/no Bearer → 401.
  • POST /connect → a consentUrl whose redirect_uri byte-matches the OAuth client's.
  • After connect: a mailboxes row (status=active), a mailbox_oauth_tokens row (ciphertext, not plaintext), a gmail_watch_state row with a history_id.
  • Send a test email to the mailbox → within ~1 min a new conversation appears.
  • Pub/Sub oldest-unacked age stays low; no rows stuck dead_lettered_at IS NOT NULL.
  • Reply from the inbox → arrives at the customer, and a reply back threads into the same conversation.

Testing

typecheck + lint + test all green — 40 files / 744 tests (+4 files / +39 tests). Includes a PGlite integration test that builds the whole composition and drives real Requests through it (inbox path, both cron endpoints, Gmail connect + webhook wiring), plus fake-backed tests for the internal endpoints, the blob adapter, and config validation.

Reviewer notes

  • fetch export vs the node:http bridge: Vercel's Node runtime supports export default { fetch(request: Request) } (verified against current Vercel docs), which hands a web Request directly — so the framework-agnostic Request => Response engine wires in with no bridge. The dev harness's src/dev/http-adapter.ts bridge stays for the bare-node:http local server only.
  • Vercel Pro required: the once-a-minute drain cron fails to deploy on Hobby (crons there run once/day). Flagged in the runbook.
  • Queue handler cast: drainHandlers maps the topic to reconcileHandler via a single narrowing cast (QueueMessage<unknown>QueueMessage<GmailReconcileJob>) — the topic string is what guarantees the payload shape; the cast is the honest boundary.
  • BlobStore.exists uses Supabase's native exists(); it isn't exercised by the engine today (only put/get are) but is implemented + tested for interface completeness.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added a unified API entrypoint that routes normal inbox traffic and two internal cron endpoints.
    • Introduced a durable Postgres-backed job queue with leasing, retries, deduplication, and dead-lettering.
    • Added a Supabase Storage-backed blob store (upload, download, signed URLs, delete, exists).
    • Added scheduled queue draining and daily watch maintenance.
  • Bug Fixes
    • Improved cron/security handling with safer, generic error responses.
  • Documentation
    • Added a Gmail inbound deployment/provisioning runbook.
  • Chores
    • Added migration script, updated configuration validation, and refreshed test coverage and expectations.

zaridan and others added 3 commits July 14, 2026 16:00
The one-time operator steps to take the merged inbound engine live: GCP
Internal OAuth app + Gmail/Pub-Sub provisioning, Supabase Postgres + Storage,
Vercel env + cron, an env-var reference, and a post-deploy smoke checklist.
Defines the endpoint/env contract the composition root builds to. Real
credentials + the consent round-trip remain the operator's action (HT-44).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…3 (HT-43)

The production QueueProvider for the RIQ dogfood — a cron-drained durable
queue on Supabase Postgres, chosen over Vercel Queues (beta) since it reuses
the DB already required. Not a toy table:

- migration 013 `queue_jobs`: run_after + locked_until (eligible + leased),
  attempts, dead_lettered_at (retained, never dropped — invariant #1), a
  partial unique index for (topic, dedupe_key) dedupe, a ready-jobs index.
- enqueue: one durable INSERT (commits before the webhook acks Pub/Sub) with
  ON CONFLICT DO NOTHING dedupe.
- drainOnce: atomic FOR UPDATE SKIP LOCKED claim (concurrent drains never
  double-process), attempts bumped at claim, ack deletes, retry reschedules
  with capped exponential backoff, dead-letter on ceiling/explicit — retained.
- getStats: ready count / oldest-ready age / dead-letter count for the smoke
  checklist + alerting.

12 PGlite-backed tests (real Postgres) incl. concurrent-drain no-double-process.
Wired only at the composition root (later in HT-43); no engine-core import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…r (HT-43)

Wire the framework-agnostic engine into a deployable Vercel app — the linchpin
that turns the merged HT-34..HT-42 engine into a running inbound-mail deployment.

- src/composition/config.ts — eager env-contract validation; aggregates all
  problems into one secret-free boot error (never echoes a value).
- src/composition/root.ts — the composition root: constructs every concrete
  adapter (PostgresDb, Gmail sender/push-verifier/watch/history, the PG queue,
  Supabase blob) and wires them into createInboxApi (gmailPush + gmailConnect
  PRESENT here — absent by default on the engine) plus the two cron closures.
  Per-instance memoized. The refresh-token encryption key is threaded to the
  token store; JWKS source built once; no secret logged.
- src/composition/app.ts — unified handler routing the CRON_SECRET-guarded
  internal cron endpoints (queue drain, watch maintenance) vs the inbox API;
  reuses authenticateRequest for the Bearer cron-secret check.
- src/providers/adapters/supabase-storage — BlobStore over Supabase Storage
  (private bucket, signed reads only, service_role server-only).
- api/[...path].ts + vercel.json — one catch-all Vercel Node function using the
  fetch Web Standard export (no node:http bridge needed; Node runtime, not
  Edge); crons: drain every minute, watch-maintenance daily 06:00 UTC.
- scripts/migrate.ts — one-shot migration runner against DATABASE_URL.
- runbook: note the Vercel Pro requirement for the sub-daily drain cron and the
  minted HELPTHREAD_SIGNING_SECRET.

Adapter boundary held: engine core imports only interfaces; concretes are wired
only at this composition root. Fake-backed tests for the internal endpoints,
the blob adapter, and config validation, plus a PGlite integration test driving
real requests through the whole composition end to end.

Gates green: typecheck + lint + test (40 files / 744 tests).

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

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7c5e3c65-97f2-4a6e-bb81-9385404524b8

📥 Commits

Reviewing files that changed from the base of the PR and between 7ea9f35 and 243811c.

📒 Files selected for processing (6)
  • specs/deploy/gmail-inbound-runbook.md
  • src/composition/config.test.ts
  • src/composition/config.ts
  • src/providers/adapters/supabase-storage/index.test.ts
  • tsconfig.json
  • vercel.json
🚧 Files skipped from review as they are similar to previous changes (6)
  • tsconfig.json
  • vercel.json
  • specs/deploy/gmail-inbound-runbook.md
  • src/providers/adapters/supabase-storage/index.test.ts
  • src/composition/config.test.ts
  • src/composition/config.ts

📝 Walkthrough

Walkthrough

Adds validated environment configuration, Supabase storage, a durable Postgres queue, unified composition-root routing, Vercel cron endpoints, migration tooling, and Gmail inbound deployment documentation with integration tests.

Changes

Gmail inbound engine

Layer / File(s) Summary
Runtime configuration contract
src/composition/config.ts, src/composition/config.test.ts
Adds typed environment parsing, aggregated validation, encryption-key decoding, URL normalization, and secret-safe error tests.
Durable queue persistence and draining
src/db/migrate.ts, src/db/*test.ts, src/providers/adapters/postgres-queue/*
Adds the queue_jobs migration and Postgres queue implementation with deduplication, leasing, retries, dead-lettering, statistics, batching, and concurrency tests.
Supabase blob storage adapter
src/providers/adapters/supabase-storage/*
Adds Supabase Storage-backed blob operations, signed URLs, byte conversion, error propagation, and adapter tests.
Composition root and unified request routing
src/composition/app.ts, src/composition/root.ts, src/composition/*test.ts, api/[...path].ts
Wires database, storage, Gmail, queue, and inbox dependencies; routes cron and inbox requests; memoizes app construction; and exposes the catch-all API handler.
Migration and deployment wiring
scripts/migrate.ts, vercel.json, tsconfig.json, package.json, specs/deploy/gmail-inbound-runbook.md
Adds migration execution, Supabase dependency wiring, TypeScript inclusion, Vercel cron configuration, and Gmail inbound deployment instructions.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Vercel
  participant AppHandler
  participant InboxAPI
  participant CronWork
  participant PostgresQueue
  Vercel->>AppHandler: forward Request
  AppHandler->>InboxAPI: delegate inbox or Gmail request
  AppHandler->>CronWork: authenticate cron request
  CronWork->>PostgresQueue: drain or inspect queue
  PostgresQueue-->>CronWork: return report
  CronWork-->>AppHandler: return JSON response
  AppHandler-->>Vercel: return Response
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is specific and accurately reflects the main change: deployable Gmail inbound infrastructure on Vercel with queue and storage wiring.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ht-43-provisioning-composition-root

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🧹 Nitpick comments (3)
tsconfig.json (1)

14-14: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Include the migration runner in typecheck coverage.

The new deployment-critical scripts/migrate.ts is outside this include list, so the project’s TypeScript validation can pass while the migration command contains a type error. Add the script explicitly or create a dedicated operator-tooling typecheck.

As per coding guidelines, continue until the result is verified rather than merely plausible.

Proposed fix
-  "include": ["src/**/*.ts", "tests/**/*.ts", "api/**/*.ts"]
+  "include": ["src/**/*.ts", "tests/**/*.ts", "api/**/*.ts", "scripts/migrate.ts"]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tsconfig.json` at line 14, Update the TypeScript configuration’s include list
to cover scripts/migrate.ts, or add a dedicated typecheck configuration that
validates this migration runner. Ensure the resulting typecheck actually
processes the migration script and verify the configuration by running the
relevant TypeScript validation.

Source: Coding guidelines

specs/deploy/gmail-inbound-runbook.md (1)

143-148: 🩺 Stability & Availability | 🔵 Trivial

Document failed-cron handling and alerting.

Vercel does not retry failed Cron invocations; the next scheduled run is a separate invocation. For the daily watch-maintenance job, add an alerting requirement or an internal retry path so a transient 500 does not remain unnoticed until the next day. (vercel.com)

Suggested runbook addition
   Vercel Cron invokes these as HTTP GETs; the handlers require the
   `CRON_SECRET` ...
+  Vercel does not retry failed invocations; alert on non-2xx responses,
+  especially for the daily watch-maintenance job.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@specs/deploy/gmail-inbound-runbook.md` around lines 143 - 148, Update the
deployment guidance around the watch-maintenance Cron job to document that
Vercel does not retry failed invocations. Add an alerting requirement or
internal retry path so transient 500 responses are detected and retried or
surfaced promptly, rather than remaining unnoticed until the next daily run.

Source: MCP tools

src/providers/adapters/supabase-storage/index.test.ts (1)

130-184: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover download and signed-URL infrastructure failures.

erroringBucket defines these failures, but the suite never invokes blob.get() or blob.getSignedUrl() against it. Add both cases to verify those errors are not swallowed.

As per coding guidelines, “Convert vague requests into verifiable success criteria ... and continue until the result is verified rather than merely plausible.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/providers/adapters/supabase-storage/index.test.ts` around lines 130 -
184, Extend the createSupabaseStorageBlobStore error-propagation tests by adding
blob.get() and blob.getSignedUrl() cases using erroringBucket with distinct
messages. Assert both promises reject with errors matching the corresponding
operation context (“get” or signed-URL) and the underlying storage message,
verifying download and signed-URL failures are surfaced.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@specs/deploy/gmail-inbound-runbook.md`:
- Line 6: Update the acceptance phrase in the runbook to hyphenate “end-to-end”
when it functions as a modifier, preserving the surrounding wording and meaning.
- Around line 202-203: Update the job-queue checklist item in the runbook so
retained rows with dead_lettered_at IS NOT NULL are not treated as failures.
Replace that condition with checks for unexpected dead-letter growth, age, or
rate, while preserving the existing monitoring requirement for the oldest ready
job age.
- Line 22: Update the ASCII architecture diagram code fence in the runbook to
specify the text language by using a text fence, while preserving the diagram
content unchanged.

In `@src/composition/config.ts`:
- Around line 215-233: Update resolvePublicBaseUrl in src/composition/config.ts
(lines 215-233) to reject HTTP(S) URLs containing credentials, non-root paths,
queries, or fragments, and return parsed.origin for valid origin-only values.
Add rejection cases covering each non-origin form in
src/composition/config.test.ts (lines 103-110).

In `@src/providers/adapters/postgres-queue/index.test.ts`:
- Around line 295-326: The current PGlite test only verifies sequential
behavior; add PostgreSQL-backed coverage using independent connections and
synchronization to force overlapping claims. Extend the queue drain test around
freshQueue and drainOnce with a barrier or short lease, and verify a stale
worker completing after another worker reclaims the same row cannot corrupt
processing or acknowledgment semantics. Preserve assertions that concurrent
workers handle jobs correctly without unintended duplicate effects.

In `@src/providers/adapters/postgres-queue/index.ts`:
- Around line 306-309: Update the sequential processing loop over claimed rows
and its outcome-write paths to fence stale workers: renew or re-claim each row
immediately before handling it, include the claimed attempts generation (or
lease token) alongside id in every delete/update WHERE clause, and verify that
exactly one row was affected before treating the outcome as successful. Apply
the same protections to the outcome handling around the symbols used at lines
339–373.
- Around line 306-373: The queue outcome handling in the processing loop must be
fenced to the worker’s claim, preventing stale workers from deleting or
rescheduling jobs after lease expiry and reclamation. In
src/providers/adapters/postgres-queue/index.ts lines 306-373, add a claim
generation or lease token to each claim, condition every ack/dead-letter delete
and retry update on that token, verify affected-row counts, and renew or reclaim
leases just in time as needed. In
src/providers/adapters/postgres-queue/index.test.ts lines 295-326, use
independent PostgreSQL connections with a barrier and short lease to verify a
stale worker cannot overwrite or delete a reclaimed attempt; preserve mail
semantics with fixture-based equivalence coverage.

---

Nitpick comments:
In `@specs/deploy/gmail-inbound-runbook.md`:
- Around line 143-148: Update the deployment guidance around the
watch-maintenance Cron job to document that Vercel does not retry failed
invocations. Add an alerting requirement or internal retry path so transient 500
responses are detected and retried or surfaced promptly, rather than remaining
unnoticed until the next daily run.

In `@src/providers/adapters/supabase-storage/index.test.ts`:
- Around line 130-184: Extend the createSupabaseStorageBlobStore
error-propagation tests by adding blob.get() and blob.getSignedUrl() cases using
erroringBucket with distinct messages. Assert both promises reject with errors
matching the corresponding operation context (“get” or signed-URL) and the
underlying storage message, verifying download and signed-URL failures are
surfaced.

In `@tsconfig.json`:
- Line 14: Update the TypeScript configuration’s include list to cover
scripts/migrate.ts, or add a dedicated typecheck configuration that validates
this migration runner. Ensure the resulting typecheck actually processes the
migration script and verify the configuration by running the relevant TypeScript
validation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f00912c8-69bf-4aaf-b322-43c24af57910

📥 Commits

Reviewing files that changed from the base of the PR and between 946c86d and 7ea9f35.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (19)
  • api/[...path].ts
  • package.json
  • scripts/migrate.ts
  • specs/deploy/gmail-inbound-runbook.md
  • src/composition/app.test.ts
  • src/composition/app.ts
  • src/composition/config.test.ts
  • src/composition/config.ts
  • src/composition/root.test.ts
  • src/composition/root.ts
  • src/db/migrate.test.ts
  • src/db/migrate.ts
  • src/db/postgres.test.ts
  • src/providers/adapters/postgres-queue/index.test.ts
  • src/providers/adapters/postgres-queue/index.ts
  • src/providers/adapters/supabase-storage/index.test.ts
  • src/providers/adapters/supabase-storage/index.ts
  • tsconfig.json
  • vercel.json

Comment thread specs/deploy/gmail-inbound-runbook.md Outdated
Comment thread specs/deploy/gmail-inbound-runbook.md Outdated
Comment thread specs/deploy/gmail-inbound-runbook.md Outdated
Comment thread src/composition/config.ts Outdated
Comment on lines +295 to +326
it('two concurrent drainOnce calls never process the same job twice (FOR UPDATE SKIP LOCKED)', async () => {
const { db, queue } = await freshQueue()
const jobCount = 10
for (let i = 0; i < jobCount; i++) {
await queue.enqueue(TOPIC, reconcileJob(i))
}

// PGlite is single-connection/in-process, so these two `drainOnce` calls
// are not necessarily racing on separate backend connections the way two
// real Supabase-backed Vercel Cron invocations would (see
// src/db/migrate.ts's `migrate()` doc comment on the same PGlite
// limitation for true concurrent-lock coverage). What this DOES prove
// unconditionally, regardless of how the two calls actually interleave:
// the claim query's WHERE clause (`locked_until IS NULL OR locked_until
// < now()`, re-checked inside the same atomic UPDATE the FOR UPDATE SKIP
// LOCKED subquery drives) never lets two calls claim the same row.
const processedIds: string[] = []
const handler: QueueMessageHandler<unknown> = async (message) => {
processedIds.push(message.id)
return { kind: 'ack' }
}

const [a, b] = await Promise.all([
queue.drainOnce({ handlers: { [TOPIC]: handler } }, { batchSize: jobCount }),
queue.drainOnce({ handlers: { [TOPIC]: handler } }, { batchSize: jobCount }),
])

expect(a.claimed + b.claimed).toBe(jobCount)
expect(processedIds).toHaveLength(jobCount)
// No id appears twice — the union of what each call processed has no overlap.
expect(new Set(processedIds).size).toBe(jobCount)
expect(await countRows(db, 'queue_jobs')).toBe(0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

This fixture does not exercise overlapping claims.

A single PGlite connection may serialize both drains, so unique processed IDs only prove sequential drains behave correctly. Add a PostgreSQL test using independent connections and a barrier/short lease, including a stale worker completing after another worker reclaims the row.

As per coding guidelines, “changes affecting mail semantics require fixture-proven equivalence.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/providers/adapters/postgres-queue/index.test.ts` around lines 295 - 326,
The current PGlite test only verifies sequential behavior; add PostgreSQL-backed
coverage using independent connections and synchronization to force overlapping
claims. Extend the queue drain test around freshQueue and drainOnce with a
barrier or short lease, and verify a stale worker completing after another
worker reclaims the same row cannot corrupt processing or acknowledgment
semantics. Preserve assertions that concurrent workers handle jobs correctly
without unintended duplicate effects.

Source: Coding guidelines

Comment on lines +306 to +309
const claimed = await claimBatch(db, topics, leaseSeconds, batchSize)
report.claimed = claimed.length

for (const row of claimed) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

Fence outcome writes against expired and reclaimed leases.

The whole batch is leased before sequential processing. Once a lease expires, another drainer can reclaim a row, but this stale worker can still delete or overwrite that newer attempt because every outcome matches only id.

Use attempts as a claim generation (or add a lease token) in every outcome WHERE, verify that one row was affected, and renew/claim leases just before sequential handling.

Also applies to: 339-373

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/providers/adapters/postgres-queue/index.ts` around lines 306 - 309,
Update the sequential processing loop over claimed rows and its outcome-write
paths to fence stale workers: renew or re-claim each row immediately before
handling it, include the claimed attempts generation (or lease token) alongside
id in every delete/update WHERE clause, and verify that exactly one row was
affected before treating the outcome as successful. Apply the same protections
to the outcome handling around the symbols used at lines 339–373.

Comment on lines +306 to +373
const claimed = await claimBatch(db, topics, leaseSeconds, batchSize)
report.claimed = claimed.length

for (const row of claimed) {
const handler = deps.handlers[row.topic]
if (handler === undefined) {
// Structurally unreachable: claimBatch's `topic IN (...)` list is
// built from exactly `Object.keys(deps.handlers)`, so every
// claimed row's topic has a registered handler. Thrown rather
// than silently skipping a claimed (leased) job.
throw new Error(
`createPostgresQueue: claimed job ${row.id} has topic '${row.topic}' with no registered handler`,
)
}

const message: QueueMessage<unknown> = {
id: row.id,
topic: row.topic,
payload: row.payload,
attempts: row.attempts,
enqueuedAt: toDate(row.created_at),
}

let result: QueueHandlerResult
let caughtErrorMessage: string | null = null
try {
result = await handler(message)
} catch (err) {
// A throw is a retry with no hint (module doc).
caughtErrorMessage = err instanceof Error ? err.message : String(err)
result = { kind: 'retry' }
}

if (result.kind === 'ack') {
await db.query('DELETE FROM queue_jobs WHERE id = $1', [row.id])
report.acked++
continue
}

if (result.kind === 'deadLetter') {
await deadLetterJob(db, row.id, result.reason)
report.deadLettered++
continue
}

// result.kind === 'retry': dead-letter once the effective ceiling is
// reached, otherwise reschedule with exponential backoff (module doc).
if (row.attempts >= maxAttempts) {
await deadLetterJob(
db,
row.id,
caughtErrorMessage ?? `createPostgresQueue: exceeded maxAttempts (${maxAttempts})`,
)
report.deadLettered++
continue
}

const base = result.backoffSeconds ?? baseBackoffSeconds
const backoffSeconds = Math.min(
base * 2 ** Math.max(0, row.attempts - 1),
maxBackoffSeconds,
)
await db.query(
`UPDATE queue_jobs
SET locked_until = NULL, run_after = now() + make_interval(secs => $2::float8), last_error = $3, updated_at = now()
WHERE id = $1`,
[row.id, backoffSeconds, caughtErrorMessage],
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

Fence queue outcomes with claim ownership and verify lease-expiry races.

Leasing the batch before sequential processing allows rows to expire and be reclaimed, while stale outcome writes still match solely by ID.

  • src/providers/adapters/postgres-queue/index.ts#L306-L373: condition every delete/update on a claim generation or lease token, verify the affected-row count, and renew or claim leases just in time.
  • src/providers/adapters/postgres-queue/index.test.ts#L295-L326: use independent PostgreSQL connections and a barrier/short lease to prove stale workers cannot overwrite or delete a reclaimed attempt.

As per coding guidelines, “changes affecting mail semantics require fixture-proven equivalence.”

📍 Affects 2 files
  • src/providers/adapters/postgres-queue/index.ts#L306-L373 (this comment)
  • src/providers/adapters/postgres-queue/index.test.ts#L295-L326
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/providers/adapters/postgres-queue/index.ts` around lines 306 - 373, The
queue outcome handling in the processing loop must be fenced to the worker’s
claim, preventing stale workers from deleting or rescheduling jobs after lease
expiry and reclamation. In src/providers/adapters/postgres-queue/index.ts lines
306-373, add a claim generation or lease token to each claim, condition every
ack/dead-letter delete and retry update on that token, verify affected-row
counts, and renew or reclaim leases just in time as needed. In
src/providers/adapters/postgres-queue/index.test.ts lines 295-326, use
independent PostgreSQL connections with a barrier and short lease to verify a
stale worker cannot overwrite or delete a reclaimed attempt; preserve mail
semantics with fixture-based equivalence coverage.

Source: Coding guidelines

…, race defense, docs)

- config.ts: PUBLIC_BASE_URL is now validated as a bare origin — reject a path,
  query, fragment, or embedded credentials (not silently strip them) and return
  URL.origin, so the redirect_uri / push `aud` concatenations can't be corrupted
  by a stray path. + rejection tests for each non-origin form.
- vercel.json: cap function maxDuration at 50s — below the 60s job lease and the
  60s cron interval — so a drain is always killed before its own leases expire
  and consecutive drains never overlap (defense-in-depth for the queue's
  lease-reclaim race; the SQL-level claim-generation fence is a tracked
  follow-up, needing a real two-connection Postgres race test).
- tsconfig: typecheck-cover scripts/migrate.ts (deploy-critical operator tool).
- supabase-storage: add download + createSignedUrl error-path tests.
- runbook: dead-letter smoke check reworded (retained dead-letters are by
  design — check growth/age/rate, not pass/fail); failed-cron alerting note;
  the maxDuration<lease constraint; end-to-end hyphen; diagram fence language.

Gates green: typecheck + lint + test (40 files / 748 tests).

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

zaridan commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

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