sdxc

[ Data & storage ]

@sdxc/cache

Cache contract with adapters for memory and Cloudflare KV

npm add @sdxc/cache
pnpm add @sdxc/cache
yarn add @sdxc/cache
bun add @sdxc/cache
Used by
uptime, reader, blog

Cache contract with adapters for memory and Cloudflare KV.

One Cache interface covers read, write, fetch and delete, and an adapter binds it to a store: an in-process map for tests, Workers KV in production. Every call answers with a Result, and each method takes the value's type on its own, so one instance serves a whole namespace.

Installation

npm add @sdxc/cache

Calls answer with a Result from @sdxc/result, where isFailure and isSuccess come from; it installs alongside this package.

Usage

Compute once, read many times

import { MemoryCache } from "@sdxc/cache/memory";
import { isFailure } from "@sdxc/result";

let cache = new MemoryCache();

let post = await cache.fetch(`post:${id}`, async () => loadPost(id), { ttl: "5 minutes" });
if (isFailure(post)) return new Response(null, { status: 500 });
return Response.json(post.data);

fetch returns the stored entry, or runs the loader on a miss and stores what it returns. The type comes from the loader, so nothing is annotated.

Read, write and delete entries

import { isSuccess } from "@sdxc/result";

await cache.write("post:1", { id: 1, title: "Hello" }, { ttl: "1 hour" });

let hit = await cache.read<{ id: number; title: string }>("post:1");
if (isSuccess(hit) && hit.data !== null) console.info(hit.data.title);

await cache.delete("post:1");

read succeeds with null for a missing or expired entry. A ttl is whole seconds or a duration string such as "1 hour"; without one the entry never expires.

Values come back as JSON

Entries are stored as JSON, and every method answers with the value as it reads back, so a hit and a miss have the same type:

let session = await cache.fetch("session", async () => ({ at: new Date() }));
// Result<{ at: string }, CacheError>: the Date reads back as its ISO string

Store entries in Workers KV

import { WorkerKVCache } from "@sdxc/cache/worker-kv";

let cache = new WorkerKVCache(env.CACHE, { waitUntil: ctx.waitUntil.bind(ctx) });

With waitUntil, a write returns at once and the put finishes after the response; the instance answers reads of that key from memory until KV holds it. Create one instance per request.

API

Cache

The contract every adapter implements, exported from @sdxc/cache.

read<T = JSONValue>(key: string): Promise<Result<JSONSerialized<T> | null, CacheError>>

Reads an entry, succeeding with null when it is missing or expired. T is an assertion about what the store holds. A stored null also reads as null; use fetch where a cached null must count as a hit.

write<T>(key: string, value: T, options?: CacheWriteOptions): Promise<Result<void, CacheError>>

Stores an entry, replacing any current value for the key. The entry is readable once the call succeeds.

fetch<T>(key: string, load: () => Promise<T>, options?: CacheWriteOptions): Promise<Result<JSONSerialized<T>, CacheError>>

Returns the stored entry, or computes, stores and returns it on a miss. A stored null is a hit. Store problems (unreachable store, an entry that is not JSON, a refused write) still answer with the computed value, so a failure means the value could not be produced: load_failed or invalid_value.

delete(key: string): Promise<Result<void, CacheError>>

Removes an entry, succeeding whether or not it exists.

CacheError

The error inside every failed Result: code, the key the call was made on, and the original error as cause.

codeMeansReached by
unavailableThe store could not be reached, or refused the operationread, write, delete
invalid_valueJSON could not write the value, or could not read the entryevery method but delete
load_failedThe loader given to fetch threw or rejectedfetch

An unavailable failure is also logged as a warning on the current invocation's log from @sdxc/logger, so a caller can treat it as a miss and still leave a trace.

new MemoryCache(options?: MemoryCacheOptions)

From @sdxc/cache/memory. Holds entries in a map for the life of the instance, serializing on write and parsing on read exactly as a remote store does; a value read back is a copy.

  • options.now: returns the current time in milliseconds, for expiring entries in a test. Defaults to Date.now.

new WorkerKVCache(kv: KVNamespace, options?: WorkerKVCacheOptions)

From @sdxc/cache/worker-kv. Stores entries in a Cloudflare KV namespace.

  • options.waitUntil: extends the invocation past the response. Given one, writes are deferred to it, the instance answers reads from those writes immediately, writes to one key land in order, and a delete waits for a pending put.

KV rejects a TTL under 60 seconds: the entry is not written and the put reports unavailable.

conformance(options: ConformanceOptions): void

From @sdxc/cache/conformance. Registers Vitest tests asserting the Cache contract against an adapter you construct.

  • options.name: labels the registered suite.

  • options.create: builds the cache under test; called once per test.

  • options.expire: moves the adapter's clock forward by a number of seconds. Leave it out and the expiry tests are skipped.

Types

CacheWriteOptions

{ ttl?: DurationInput }, where DurationInput from @sdxc/duration is whole seconds or a duration string.

CacheErrorCode

"unavailable" | "invalid_value" | "load_failed".

CacheErrorOptions

What new CacheError(message, options) takes: code, key, and the standard cause. An adapter of your own builds its failures with it.

MemoryCacheOptions, WorkerKVCacheOptions, ConformanceOptions

The options objects described above.

Pattern: Accept Any Cache, Test With Memory

Type a dependency as Cache so production passes the KV adapter and a test passes a memory one.

import type { Cache } from "@sdxc/cache";
import { MemoryCache } from "@sdxc/cache/memory";
import { isFailure } from "@sdxc/result";
import { expect, test, vi } from "vitest";

interface Post {
	id: string;
	title: string;
}

async function publishedPosts(cache: Cache, load: () => Promise<Post[]>) {
	let posts = await cache.fetch("posts:published", load, { ttl: "5 minutes" });
	return isFailure(posts) ? [] : posts.data;
}

test("loads the posts once", async () => {
	let cache = new MemoryCache();
	let load = vi.fn(async () => [{ id: "1", title: "Hello" }]);

	await publishedPosts(cache, load);
	await publishedPosts(cache, load);

	expect(load).toHaveBeenCalledTimes(1);
});

Pattern: Expire Entries On A Test Clock

Pass now to control time, and an entry expires without waiting for it.

import { MemoryCache } from "@sdxc/cache/memory";
import { unwrap } from "@sdxc/result";
import { expect, test } from "vitest";

test("an entry expires after its ttl", async () => {
	let clock = 0;
	let cache = new MemoryCache({ now: () => clock });

	unwrap(await cache.write("token", "abc", { ttl: "1 minute" }));
	clock += 59_000;
	expect(unwrap(await cache.read("token"))).toBe("abc");

	clock += 1_000;
	expect(unwrap(await cache.read("token"))).toBeNull();
});

Pattern: Cache Per Request In A Worker

Build the KV adapter inside the handler so read-your-own-writes stays scoped to one request, and key entries by what they hold so a shared namespace stays readable.

import { WorkerKVCache } from "@sdxc/cache/worker-kv";
import { isFailure } from "@sdxc/result";

interface Env {
	CACHE: KVNamespace;
}

export default {
	async fetch(request: Request, env: Env, ctx: ExecutionContext) {
		let cache = new WorkerKVCache(env.CACHE, { waitUntil: ctx.waitUntil.bind(ctx) });
		let locale = new URL(request.url).searchParams.get("locale") ?? "en";

		let feed = await cache.fetch(`feed:${locale}`, async () => buildFeed(locale), {
			ttl: "10 minutes",
		});
		if (isFailure(feed)) return new Response("Feed unavailable", { status: 503 });

		return Response.json(feed.data);
	},
};

Pattern: Invalidate After A Write

Delete the cached entry when the source changes, and the next fetch recomputes it.

import type { Cache } from "@sdxc/cache";

async function renamePost(cache: Cache, id: string, title: string) {
	await savePostTitle(id, title);
	await cache.delete(`post:${id}`);
}

Pattern: Rethrow Failures

A caller that prefers exceptions rethrows the original error, keeping its own error type.

import type { Cache } from "@sdxc/cache";
import { isFailure } from "@sdxc/result";

async function cachedReport(cache: Cache, month: string) {
	let report = await cache.fetch(`report:${month}`, async () => buildReport(month), {
		ttl: "1 day",
	});
	if (isFailure(report)) throw report.error.cause ?? report.error;
	return report.data;
}

Pattern: Verify A Custom Adapter

Implement Cache over another store, answer its failures with a CacheError, and run the conformance suite against it in a test file.

import type { Cache } from "@sdxc/cache";
import { CacheError } from "@sdxc/cache";
import { conformance } from "@sdxc/cache/conformance";
import { failure } from "@sdxc/result";

export function unavailable(key: string, cause: unknown) {
	return failure(
		new CacheError(`The store could not reach ${key}`, { code: "unavailable", key, cause }),
	);
}

conformance({
	name: "RedisCache",
	create: (): Cache => new RedisCache(redis),
});

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.