[GH-ISSUE #623] RFC: Per-request store API #132

Closed
opened 2026-05-06 12:37:28 +02:00 by BreizhHardware · 4 comments

Originally created by @james-elicx on GitHub (Mar 21, 2026).
Original GitHub issue: https://github.com/cloudflare/vinext/issues/623

This issue is a request for feedback on the public API for our per-request store API. The idea was originally proposed in #608.

Internally, the store would interact with a map that's inside a request-scoped AsyncLocalStorage.

I've been doing some thinking this morning about the public API that we'd be exposing, as it would be nice have it well-defined from the get-go to avoid potentially introducing breaking changes in the future.

Store Interface

My thinking is that we would want to expose full control for interacting with the underlying store, which also providing some higher-level abstractions to reduce boilerplate.

The store itself would expose the following:

// checks the underlying map.
// does type-narrowing so that get(key) calls in if-statements have the non-undefined type.
has(key: string): boolean;
// returns value from underlying map. narrowed when combined with has(key).
get(key: string): unknown | undefined;
// inserts value to underlying map and returns the value back.
set(key: string, value: unknown): unknown;
// checks if the map already has a value and returns it, otherwise sets the value and returns it.
getOrSet(key: string, factory: () => unknown): unknown;

Methods accepting a callback may possibly need async variants as well, or the other way around with async as the default and sync as the variant, or neither if we find a nice way to make both work in one function.

Store Consumption

I had been thinking through a few different ways to approach this, and the following is what I arrived at for a nice balance of type-safety and control.

It would be great to get thoughts from others, or suggestions if you would like to see something else!

Function that returns a type-safe interface to the underlying map

import { createRequestStore } from 'vinext/server';

// safe to be called in global scope as it doesn't access the inner store
const store = createRequestStore<{ prisma: PrismaClient }>();

const getPrisma = () =>
  store.getOrSet("prisma", () => {
    const pool = new Pool();
    return new PrismaClient(pool);
  });

const getPrisma = () => {
  if (store.has("prisma")) {
    return store.get("prisma"); // type-narrowed by has(...)
  }

  const pool = new Pool();
  const client = new PrismaClient(pool);

  return store.set("prisma", client);
};

The alternative approach that was on my mind would be a Proxy to the underlying map where you pass the type stored to each function call, but that would sacrifice some of the type-safety.

Abstraction on top of the store for callbacks

Another option that would be interesting to explore is a cacheForRequest function, that takes a callback and lazily initialises the value in the request store on first access. Similar to how React's cache function works, but not limited to server components.

import { cacheForRequest } from 'vinext/server';

// return a function that lazily initializes the Prisma client on the first call
const getPrisma = cacheForRequest(() => {
  const pool = new Pool();
  return new PrismaClient(pool);
});
Originally created by @james-elicx on GitHub (Mar 21, 2026). Original GitHub issue: https://github.com/cloudflare/vinext/issues/623 This issue is a **request for feedback on the public API** for our per-request store API. The idea was originally proposed in #608. Internally, the store would interact with a map that's inside a request-scoped `AsyncLocalStorage`. I've been doing some thinking this morning about the public API that we'd be exposing, as it would be nice have it well-defined from the get-go to avoid potentially introducing breaking changes in the future. ## Store Interface My thinking is that we would want to expose full control for interacting with the underlying store, which also providing some higher-level abstractions to reduce boilerplate. The store itself would expose the following: ```ts // checks the underlying map. // does type-narrowing so that get(key) calls in if-statements have the non-undefined type. has(key: string): boolean; // returns value from underlying map. narrowed when combined with has(key). get(key: string): unknown | undefined; // inserts value to underlying map and returns the value back. set(key: string, value: unknown): unknown; // checks if the map already has a value and returns it, otherwise sets the value and returns it. getOrSet(key: string, factory: () => unknown): unknown; ``` Methods accepting a callback may possibly need async variants as well, or the other way around with async as the default and sync as the variant, or neither if we find a nice way to make both work in one function. ## Store Consumption I had been thinking through a few different ways to approach this, and the following is what I arrived at for a nice balance of type-safety and control. It would be great to get thoughts from others, or suggestions if you would like to see something else! **Function that returns a type-safe interface to the underlying map** ```ts import { createRequestStore } from 'vinext/server'; // safe to be called in global scope as it doesn't access the inner store const store = createRequestStore<{ prisma: PrismaClient }>(); const getPrisma = () => store.getOrSet("prisma", () => { const pool = new Pool(); return new PrismaClient(pool); }); const getPrisma = () => { if (store.has("prisma")) { return store.get("prisma"); // type-narrowed by has(...) } const pool = new Pool(); const client = new PrismaClient(pool); return store.set("prisma", client); }; ``` The alternative approach that was on my mind would be a Proxy to the underlying map where you pass the type stored to each function call, but that would sacrifice some of the type-safety. **Abstraction on top of the store for callbacks** Another option that would be interesting to explore is a `cacheForRequest` function, that takes a callback and lazily initialises the value in the request store on first access. Similar to how React's `cache` function works, but not limited to server components. ```ts import { cacheForRequest } from 'vinext/server'; // return a function that lazily initializes the Prisma client on the first call const getPrisma = cacheForRequest(() => { const pool = new Pool(); return new PrismaClient(pool); }); ```
Author
Owner

@JamesbbBriz commented on GitHub (Mar 22, 2026):

One thing worth flagging with createRequestStore — since the underlying Map is shared per-request, two unrelated modules using the same key will collide silently:

// lib/db.ts
const store = createRequestStore<{ client: PrismaClient }>();
store.getOrSet("client", () => new PrismaClient());

// lib/cache.ts
const store2 = createRequestStore<{ client: RedisClient }>();
store2.getOrSet("client", () => new RedisClient()); // oops, gets PrismaClient

cacheForRequest doesn't have this problem since the function reference itself acts as the key.

Also — for the async case, I think caching the Promise (not the resolved value) is important. Otherwise two concurrent calls during the first await both trigger the factory:

const getDb = cacheForRequest(async () => new PrismaClient({ adapter }));

// concurrent calls in same request
const [a, b] = await Promise.all([getDb(), getDb()]);
// should return same Promise, not create two clients
<!-- gh-comment-id:4105605517 --> @JamesbbBriz commented on GitHub (Mar 22, 2026): One thing worth flagging with `createRequestStore` — since the underlying Map is shared per-request, two unrelated modules using the same key will collide silently: ```ts // lib/db.ts const store = createRequestStore<{ client: PrismaClient }>(); store.getOrSet("client", () => new PrismaClient()); // lib/cache.ts const store2 = createRequestStore<{ client: RedisClient }>(); store2.getOrSet("client", () => new RedisClient()); // oops, gets PrismaClient ``` `cacheForRequest` doesn't have this problem since the function reference itself acts as the key. Also — for the async case, I think caching the Promise (not the resolved value) is important. Otherwise two concurrent calls during the first await both trigger the factory: ```ts const getDb = cacheForRequest(async () => new PrismaClient({ adapter })); // concurrent calls in same request const [a, b] = await Promise.all([getDb(), getDb()]); // should return same Promise, not create two clients ```
Author
Owner

@james-elicx commented on GitHub (Mar 22, 2026):

In theory you would only ever need to create the store once, and you can then re-use it everywhere with type-safety, since it would essentially just be a type utility.

Good shout on the promises

<!-- gh-comment-id:4105883161 --> @james-elicx commented on GitHub (Mar 22, 2026): In theory you would only ever need to create the store once, and you can then re-use it everywhere with type-safety, since it would essentially just be a type utility. Good shout on the promises
Author
Owner

@JamesbbBriz commented on GitHub (Mar 28, 2026):

Implemented the cacheForRequest() API in #646 — it covers the higher-level factory cache use case from this RFC. Uses function identity as the cache key with a WeakMap to avoid memory leaks, and includes rejected-Promise self-healing. Would love feedback on whether this aligns with the direction you had in mind.

<!-- gh-comment-id:4147244821 --> @JamesbbBriz commented on GitHub (Mar 28, 2026): Implemented the `cacheForRequest()` API in #646 — it covers the higher-level factory cache use case from this RFC. Uses function identity as the cache key with a `WeakMap` to avoid memory leaks, and includes rejected-Promise self-healing. Would love feedback on whether this aligns with the direction you had in mind.
Author
Owner

@james-elicx commented on GitHub (Mar 28, 2026):

I'll close this as completed with the cacheForRequest() utility for now - can re-visit if a lower-level API is needed.

<!-- gh-comment-id:4147703965 --> @james-elicx commented on GitHub (Mar 28, 2026): I'll close this as completed with the `cacheForRequest()` utility for now - can re-visit if a lower-level API is needed.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/vinext#132
No description provided.