The single retry / caching / in-flight-dedup / scheduling primitive used across this monorepo. Wraps async functions with caching, in-flight deduplication, retries, and optional Standard Schema validation.
Lives inside action/ so the OSS publish (action/ rsynced to pullfrog/pullfrog, then npm publish pullfrog) stays self-contained — no sibling workspace package. Import as from "pullfrog/yes" from root code, or relatively from "../yes/index.ts" from inside action/.
Wrap any async function with op to get caching, deduplication, and retries for free.
import { op } from "pullfrog/yes";
const getUser = op(async (id: string) => {
return db.users.findUnique({ where: { id } });
}, { ttl: 60_000 });
await getUser("abc"); // executes
await getUser("abc"); // cache hitWithout ttl, there's no LRU cache — but concurrent calls with the same key are still deduplicated:
const getUser = op(async (id: string) => {
return db.users.findUnique({ where: { id } });
});
// only one DB call is made
const [a, b] = await Promise.all([getUser("abc"), getUser("abc")]);op(fn, {
name: "getUser", // label for log output
ttl: 60_000, // LRU cache TTL in ms (no cache without this)
maxItems: 1000, // max LRU entries (default: 1000)
retries: [100, 500], // retry delays in ms
retryAfter: true, // honor a retry-after / x-ratelimit-reset header on retries
// (true = built-in reader, or a (err) => number | undefined)
retryAfterCap: 20_000, // cap (ms) on a honored retry-after hint (default: 20000)
bail: (err) => boolean, // return true to abort retries immediately
skipCache: (result) => boolean, // return true to skip caching this result
cacheHit: (key) => {}, // called on cache hit (null to silence)
cacheMiss: (key) => {}, // called on cache miss (null to silence)
});Every op function gets clear, has, and invalidate methods:
const getRepo = op(async (input: { org: string; repo: string }) => {
return github.repos.get(input);
}, { ttl: 60_000 });
getRepo.has({ org: "acme", repo: "app" }); // => boolean
getRepo.clear({ org: "acme", repo: "app" }); // clear one key
getRepo.clear(); // clear all
// predicate-based eviction
getRepo.invalidate((key) => key.org === "acme"); // => number removedThe first argument is used as the cache key. Strings pass through directly; objects are hashed with object-hash (order-independent):
await getRepo({ org: "acme", repo: "app" });
await getRepo({ repo: "app", org: "acme" }); // cache hit — same hashnull cache keys throw. undefined maps to a shared sentinel (VOID_KEY), so zero-arg ops can be wrapped purely for retry/dedup semantics — all no-arg invocations share one cache slot.
If your function accepts a second argument, it's treated as a context parameter — passed through to the function but not included in the cache key:
const getRepo = op(async (id: string, ctx: { token: string }) => {
return github.repos.get({ id, token: ctx.token });
}, { ttl: 60_000 });
await getRepo("123", { token: "abc" }); // executes
await getRepo("123", { token: "xyz" }); // cache hit (ctx ignored)Pass an array of delay values in ms. The function will retry up to retries.length times:
const getUser = op(async (id: string) => {
return api.getUser(id);
}, {
retries: [100, 500, 2000],
});Use bail to abort retries early on non-transient errors:
const getUser = op(async (id: string) => {
return api.getUser(id);
}, {
retries: [100, 500, 2000],
bail: (err) => err instanceof NotFoundError,
});The object form accepts optional input and output schemas conforming to the Standard Schema spec (Zod, Valibot, ArkType, etc.):
import { z } from "zod";
const getUser = op({
input: z.string(),
output: z.object({ id: z.string(), name: z.string() }),
run: async (id) => {
return db.users.findUnique({ where: { id } });
},
ttl: 60_000,
retries: [100, 500],
});Input is validated before run is called. Output is validated before the result is returned. All OpOptions (ttl, retries, name, etc.) work the same way.
The object form also supports a ctx parameter on run:
const getUser = op({
input: z.string(),
run: async (id, ctx: { requestId: string }) => {
return { id, requestId: ctx.requestId };
},
});
await getUser("abc", { requestId: "req-1" });null and undefined return values are never cached, even when ttl is set.
Build an array of retry delays from a formula:
import { schedule } from "pullfrog/yes";
schedule((i) => 100 * 2 ** i, 4);
// => [100, 200, 400, 800]
schedule((i) => (i + 1) * 500, 3);
// => [500, 1000, 1500]Designed to plug directly into retries:
const getUser = op(async (id: string) => api.getUser(id), {
retries: schedule((i) => 100 * 2 ** i, 4),
});Small array helper with three overloads:
import { range } from "pullfrog/yes";
range(5); // => [0, 1, 2, 3, 4]
range(1, 5); // => [1, 2, 3, 4, 5]
range(4, (i) => i ** 2); // => [0, 1, 4, 9]