
# Storage

Every cached function and handler owns its storage. By default each instance gets a **fresh in-memory store**, so nothing is shared and nothing is global: two cached functions using the default storage can never see each other's entries — even with the same name and key — and independent libraries in one process cannot collide by accident.

To share a cache, pass the _same_ `storage` to every function and handler that should use it. To persist entries, pass any backend that implements the small `StorageInterface`.

## The Storage Interface

A backend needs only two methods. Both may be synchronous or return a promise:

```ts
interface StorageInterface {
  get<T = unknown>(key: string): T | null | Promise<T | null>;
  set<T = unknown>(key: string, value: T, opts?: { ttl?: number }): void | Promise<void>;

  /** Optional: the largest byte charge one entry may have, if the backend enforces one. */
  maxEntryBytes?: number;

  /** Optional: set this only if the backend returns a stored `Uint8Array` as itself. */
  binary?: boolean;
}
```

- `get(key)` returns the stored value, or `null` when the key is missing.
- `set(key, value, opts)` stores a value. `opts.ttl` is an optional lifetime in **seconds**.
- Setting a **nullish** value (`null` or `undefined`) **deletes** the entry. Invalidation reaches your backend through this path, so your `set` must handle it.
- `maxEntryBytes` declares the backend's per-entry ceiling. Cached handlers derive their [response-body limit](/docs/handler#response-size) from it, so bodies the backend could never store are refused before they are buffered.
- `binary` declares that values come back with their byte views intact. Cached handlers then store a binary response body as a `Uint8Array` instead of base64 text, and [cached functions](/docs/functions#binary-values) do the same for a value that is bytes, which drops the encode on every store, the decode on every hit, and the 4/3 expansion in your backend. **Only set this if it is true.** A backend that serializes entries — JSON, and most persistent ones — returns a view as `{"0":255,...}`; ocache rejects a value in that shape as a miss rather than serving it, so a wrong declaration costs you every binary hit. Memory storage declares it, because it holds values by reference; for the same reason nothing may mutate a value it hands back.

## Custom Storage

Pass your backend as the `storage` option. A Redis example:

```ts
import { defineCachedFunction } from "ocache";
import type { StorageInterface } from "ocache";

const redisStorage: StorageInterface = {
  async get(key) {
    const raw = await redis.get(key);
    return raw === null ? null : JSON.parse(raw);
  },
  async set(key, value, opts) {
    // A nullish value means "delete this entry" (used by invalidation).
    if (value === null || value === undefined) {
      await redis.del(key);
      return;
    }
    // opts.ttl is in seconds — Redis' EX option expects seconds too.
    await redis.set(key, JSON.stringify(value), opts?.ttl ? { EX: opts.ttl } : undefined);
  },
};

const getUser = defineCachedFunction(fetchUser, { maxAge: 60, storage: redisStorage });
```

The interface is small enough that any [unstorage](https://unstorage.unjs.io) driver — filesystem, Cloudflare KV, S3, Redis, and many more — fits behind a tiny adapter:

```ts
import { createStorage } from "unstorage";
import redisDriver from "unstorage/drivers/redis";

const unstorage = createStorage({ driver: redisDriver({/* ... */}) });

const storage: StorageInterface = {
  get: (key) => unstorage.getItem(key),
  set: (key, value, opts) =>
    value === null || value === undefined
      ? unstorage.removeItem(key)
      : unstorage.setItem(key, value, { ttl: opts?.ttl }),
};
```

`setItem` serializes the entry as JSON, so this adapter must **not** declare [`binary`](#the-storage-interface): a `Uint8Array` body would come back as `{"0":255,...}`. Binary bodies and binary function values take the base64 path here, which is what that path is for.

### Keeping bodies as bytes on a byte-only driver

Many drivers — `fs`, `redis`, `s3`, `cloudflare-r2-binding`, `netlify-blobs`, and others — implement `getItemRaw`/`setItemRaw` natively, and the rest fall back to base64 inside unstorage. Raw moves **one byte payload**, not an object graph: the `fs` driver writes the value with `writeFile` and reads it back with `readFile`, so handing it a cache entry throws `ERR_INVALID_ARG_TYPE`.

`createBlobStorage` adapts any such backend. It stores each entry as one frame — a small JSON header, then the entry's payload appended as bytes:

```ts
import { createBlobStorage } from "ocache";
import fsDriver from "unstorage/drivers/fs";

const unstorage = createStorage({ driver: fsDriver({ base: "./.cache" }) });

const storage = createBlobStorage({
  get: (key) => unstorage.getItemRaw(key),
  set: (key, value, opts) =>
    value === null ? unstorage.removeItem(key) : unstorage.setItemRaw(key, value, opts),
});
```

That is the whole adapter. An image lands on disk as its own bytes — no base64 anywhere, so no encode on a store, no decode on a hit, and no 4/3 expansion in the store. A **text** body is written as its UTF-8 bytes too, so it pays no JSON escaping in either direction. On a driver with no native raw support, unstorage base64-encodes the one blob for you, which is what the plain adapter above already paid.

Three things worth knowing:

- **The payload is declared, not detected.** Cached handlers name the response body, and cached functions name a value that is bytes. A `Uint8Array` you bury somewhere else in a value is JSON on this backend, exactly as it would be on any other serializing one — pass [`payload: "value.body"`](/docs/functions#binary-values) if your own `serialize` produces a `{ body }` shape.
- **A frame from another version reads as a miss.** If the format changes, entries already in your store are re-resolved rather than misread. That costs one revalidation each.
- **`binary` is already declared for you.** Do not wrap `createBlobStorage` in another adapter that unsets it.

> [!TIP]
> A persistent backend outlives the process, so it needs deterministic keys across restarts. ocache's generated names (including the source-hash fallback) are stable, but review [cache keys](/docs/functions#cache-keys) if you rely on anonymous functions.

## Sharing a Storage

Pass one instance to every cached function or handler that should share a cache:

```ts
const storage = createMemoryStorage();

const getUser = defineCachedFunction(fetchUser, { maxAge: 60, storage });
const getPosts = defineCachedFunction(fetchPosts, { maxAge: 60, storage });
```

Entries are still keyed by `base`, `group`, and `name`, so a shared backend does not mean shared _entries_ — it means one connection, one eviction budget, and one place to purge.

> [!IMPORTANT]
> The `name` is what separates entries. It defaults to the function's or [handler's](/docs/handler#cache-key-name) name, falling back to a hash of the source. Instances produced by a factory or loop have identical source and therefore the **same** name — on shared storage they also share entries. Give such instances an explicit `name`.

## Late Binding

Handlers are usually defined at module load, but the real backend may not exist until the server starts. Pass a **factory** instead of an instance: ocache calls it on the first cache read or write — not at definition time — and at most once per cached function or handler.

```ts
let storage: StorageInterface;

const handler = defineCachedHandler(renderPage, {
  maxAge: 60,
  storage: () => storage, // resolved on first use, once
});

// ...later, at server start
storage = redisStorage;
```

> [!NOTE]
> ocache writes the resolved storage back onto the options object you passed. The standalone `invalidateCache()` and `expireCache()` helpers need that original options object — or the same backend — and **throw** when `options.storage` is unset. `resolveCacheKeys()` only computes keys and needs no storage. See [Invalidation & Expiration](/docs/invalidation).

## Memory Storage & LRU

The default backend comes from `createMemoryStorage` — each cached function or handler gets its own. It holds at most **10 000 entries** and **100 MB**, and evicts the least recently used entries when either limit is exceeded. Reading or writing a key makes it most recently used.

The two limits control different things: `maxSize` caps the entry **count**, `maxBytes` caps **memory**. `maxSize` alone does not bound memory — retained bytes are `maxSize × entry size`, and for cached HTTP responses your pages set the entry size. 10 000 entries of 1 MB is 10 GB, and a process cannot catch an out-of-memory kill. Set a limit to `Infinity` or `0` to disable it:

```ts
import { createMemoryStorage } from "ocache";

// Change the ceilings.
const storage = createMemoryStorage({
  maxSize: 50_000,
  maxBytes: 512 * 1024 * 1024,
});

// Opt out of the entry ceiling, keep the byte budget.
const bigCache = createMemoryStorage({ maxSize: Number.POSITIVE_INFINITY });

const cached = defineCachedFunction(fn, { maxAge: 60, storage });
```

### How bytes are counted

The byte value is a deliberate **overestimate** — a budget that estimates low is not a reliable limit:

- Strings (including cached response bodies) count 2 bytes per character, the upper bound an engine can retain.
- A binary view charges the whole `ArrayBuffer` it keeps alive, even when it covers only part of it.
- Key bytes and a small per-entry overhead are included.

Pass `sizeOf` to replace the estimate with your own full per-entry charge:

```ts
const storage = createMemoryStorage({
  maxBytes: 64 * 1024 * 1024,
  sizeOf: (value, key) => key.length + JSON.stringify(value).length,
});
```

### Refused entries

- **An entry larger than `maxBytes` by itself is not stored**, and any previous value under its key is removed. The rest of the cache is not evicted to make room for something that still cannot fit — one huge response cannot wipe all other entries. Raise `maxBytes` if you cache a few very large values.
- **An immeasurable value is refused too** — for example an object whose property getter throws. Storing it free of charge would defeat the budget. Provide `sizeOf` to cache such values at a charge you control.

> [!NOTE]
> `maxBytes` also bounds what cached **handlers** buffer: a response body too large for one entry is refused _while it streams_, before the whole body sits in memory. See [`maxBodySize`](/docs/handler#response-size).

Memory storage honors per-entry TTLs: expired entries return `null` and are cleaned up automatically.

## Layered Storage

`composeStorage` combines several backends into **one**. The usual shape is a fast local layer in front of a shared remote one:

```ts
import { composeStorage, createBlobStorage, createMemoryStorage } from "ocache";

const storage = composeStorage([
  { storage: createMemoryStorage({ maxBytes: 64 * 1024 * 1024 }), ttl: 60 },
  createBlobStorage(redis),
]);

const cached = defineCachedHandler(handler, { maxAge: 3600, swr: true, storage });
```

- **Reads** try each layer in order and stop at the first hit.
- **A hit from a later layer is promoted** into every earlier one, so the next read is local.
- **Writes and deletes reach every layer**, so invalidation is never partial.
- **A layer that throws is skipped, never fatal.** A read falls through to the next layer and a write continues to the others, so a shared backend can be down while the local one still serves.

What the cache sees is one backend, so cache keys, entries, `base`, and every [purge helper](/docs/invalidation) work exactly as they do with a single store. Layers are instances, not factories: for [late binding](#late-binding), pass a factory that builds the whole stack (`storage: () => composeStorage([...])`).

### Per-layer lifetimes

`ttl` caps what the cache asks a layer to store, in seconds — keep an entry for an hour in Redis and a minute in memory:

```ts
composeStorage([
  { storage: createMemoryStorage(), ttl: 60 }, // never held longer than a minute
  createBlobStorage(redis), // whatever `maxAge` asks for
]);
```

It is also the **only** lifetime a promotion has, because a promotion carries no lifetime of its own. A layer without a cap holds a promoted entry until its own backend evicts it — for memory storage that is `maxSize`/`maxBytes` doing the work. This bounds occupancy, not freshness: whether an entry is still fresh is decided from the entry itself, so an over-long layer TTL costs memory, never a stale response.

### What the stack declares

A composed stack has to publish one `binary` and one `maxEntryBytes`, and the two rules differ:

- **`binary` is declared only when every layer declares it.** One stored form is chosen for the whole stack, so the weakest layer decides. Both built-in backends declare it, so `createMemoryStorage` in front of `createBlobStorage` keeps bodies as bytes; adding a layer that serializes entries falls the whole stack back to base64.
- **`maxEntryBytes` is the largest layer's ceiling**, and unset if any layer declares none. An entry only has to fit the layer that ends up holding it: a response too large for a small memory layer is refused by that layer alone — quietly, as it already is today — and served from the layer that can hold it.

### Options

```ts
composeStorage(layers, {
  promote: false, // do not write a later hit back to the earlier layers
  onError: (error, key) => report(error), // defaults to console.error
});
```

Promotions run in the background so a read never waits for them. A `set` for the same key waits for one first, so a purge cannot be undone by a promotion it raced.

## Multi-tier Keys

Storage layers are separate backends. `base` is the other axis: it prefixes every cache key, and an **array** of prefixes tiers the keyspace _within_ one backend:

- **Reads** try each prefix in order and stop at the first hit.
- **Misses** write the entry under _every_ prefix.
- **Revalidation** writes the hit prefix and every earlier prefix, promoting the entry toward the front.

```ts
import { defineCachedFunction } from "ocache";

const cachedFetch = defineCachedFunction(
  async (url: string) => {
    const res = await fetch(url);
    return res.json();
  },
  {
    maxAge: 60,
    base: ["/edge", "/origin"], // read "/edge" first, then "/origin"; write both
  },
);
```

A single string keeps everything in one tier; the default is `"/cache"`. Reach for `composeStorage` when the tiers are different _stores_, and for `base` when they are different namespaces in the same one. Invalidation and expiration always operate across all tiers of both — see [Invalidation & Expiration](/docs/invalidation).
