Skip to content

feat: add per-request store API (getRequestStore)#607

Closed
JamesbbBriz wants to merge 3 commits intocloudflare:mainfrom
JamesbbBriz:feat/per-request-store
Closed

feat: add per-request store API (getRequestStore)#607
JamesbbBriz wants to merge 3 commits intocloudflare:mainfrom
JamesbbBriz:feat/per-request-store

Conversation

@JamesbbBriz
Copy link

@JamesbbBriz JamesbbBriz commented Mar 20, 2026

Summary

Adds a user-accessible per-request key-value store backed by vinext's existing AsyncLocalStorage-based unified request context.

Primary use case: per-request database clients on Cloudflare Workers. Global DB singletons cause alternating request failures (#537) because Workers isolates reuse across requests but I/O contexts are per-request.

getRequestStore() gives each request its own Map that's automatically garbage-collected when the request completes — no manual cleanup, no TTL hacks.

API

import { getRequestStore } from "vinext/request-store";

// Per-request Prisma client (solves #537):
export function getPrisma(connectionString: string): PrismaClient {
  const store = getRequestStore();
  let prisma = store.get("prisma") as PrismaClient | undefined;
  if (!prisma) {
    const pool = new Pool({ connectionString });
    prisma = new PrismaClient({ adapter: new PrismaPg(pool) });
    store.set("prisma", prisma);
  }
  return prisma;
}

// Works with Drizzle too:
export function getDb(connectionString: string) {
  const store = getRequestStore();
  let db = store.get("db");
  if (!db) {
    db = drizzle(connectionString);
    store.set("db", db);
  }
  return db;
}

Motivation

We're running a production SaaS (OptiTalent) on vinext + Cloudflare Workers with Prisma v7 + Hyperdrive + R2. We hit the exact alternating-failure pattern described in #537 and initially worked around it with a 50ms TTL heuristic. It works, but it's fragile — the correct solution is framework-level per-request scoping.

vinext already has the infrastructure for this via UnifiedRequestContext + AsyncLocalStorage. This PR simply exposes it to users via a clean public API, similar to:

  • SvelteKit event.locals
  • Hono c.set() / c.get()
  • Remix context

Scope & Limitations

getRequestStore() works in any code path wrapped by runWithRequestContext() (the unified ALS scope):

Code path Supported Notes
App Router route handlers (app/api/*/route.ts) Wrapped by RSC entry
App Router server components Wrapped by RSC entry
App Router server actions Wrapped by RSC entry
Pages Router API routes (pages/api/*) Wrapped in 985e6ec (both dev + prod)
Middleware (middleware.ts) Has runWithExecutionContext, inherits into unified scope

Changes

File Change
packages/vinext/src/shims/unified-request-context.ts Add userStore: Map<string, unknown> to UnifiedRequestContext
packages/vinext/src/shims/request-store.ts NewgetRequestStore() public API
packages/vinext/package.json Add "./request-store" export

Test plan

  • Verified in production with Prisma v7 + Hyperdrive (per-request client, zero alternating failures)
  • Unit test: getRequestStore() returns isolated Maps across concurrent runWithRequestContext() calls
  • Unit test: getRequestStore() returns fallback Map outside request scope

Closes #537

Add a user-accessible per-request key-value store backed by vinext's
existing AsyncLocalStorage-based unified request context.

Primary use case: per-request database clients on Cloudflare Workers.
Global DB singletons cause alternating request failures (cloudflare#537) because
Workers isolates reuse across requests but I/O contexts are per-request.
getRequestStore() gives each request its own Map that's automatically
garbage-collected when the request completes.

API:
  import { getRequestStore } from 'vinext/request-store';
  const store = getRequestStore();
  store.set('prisma', new PrismaClient({ adapter }));
  store.get('prisma'); // same instance within this request

Similar pattern to SvelteKit locals, Hono c.set/get, Remix context.

Closes cloudflare#537
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f7cdddfc4a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


// Fallback store for calls outside a request scope (dev server top-level, tests).
// Ephemeral — not persisted across calls, prevents crashes but data won't survive.
const _fallbackStore = new Map<string, unknown>();

Choose a reason for hiding this comment

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

P2 Badge Return a fresh fallback store outside request scope

getRequestStore() is documented as non-persistent outside unified scope, but _fallbackStore is a module-global Map, so every out-of-scope call shares and retains the same data. In practice this can leak request-specific objects (for example DB clients keyed by dynamic values) across unrelated calls/tests and grow unbounded over process lifetime, which defeats the isolation guarantees this API is meant to provide when context propagation is missing.

Useful? React with 👍 / 👎.

Address review feedback: _fallbackStore was a module-global Map shared
across all out-of-scope calls, which could leak request-specific data
(like DB clients) across unrelated calls and grow unbounded.

Now returns a new Map() each time when outside unified scope.
@JamesbbBriz
Copy link
Author

Local verification

Tested with our production app (Prisma v7 + Hyperdrive + Cloudflare Workers Free plan):

  • npm run build:vinext passes with the new request-store.ts in the bundle
  • Per-request Prisma client via getRequestStore() — zero alternating failures across 500+ requests
  • Outside-scope fallback returns fresh Map each call (addressed review feedback in 371d00d)

Happy to add unit tests if needed — let me know the preferred test framework/location.

Pages Router API routes (pages/api/*) were the only code path missing
a runWithRequestContext scope — both dev server (api-handler.ts) and
prod/Workers (pages-server-entry.ts) now wrap the handler call.

This ensures getRequestStore() and other per-request APIs work
consistently across all code paths (App Router already had this).
@JamesbbBriz
Copy link
Author

Superseded by a cleaner PR — squashed commits and addressed all review feedback.

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.

Alternating request failures (success, fail, success...) when using Drizzle + Postgres lazily evaluated in vinext / Cloudflare Workers

1 participant