npm package: @inlay/cache
Description: Every Inlay XRPC component response may include a [`CachePolicy`](https://pdsls.dev/at://did:plc:mdg3w2kpadcyxy33pizokzf3/com.atproto.lexicon.schema/at.inlay.defs#schema:cachePolicy) — a lifetime and a set of invalidation tags:
Author: Dan Abramov
Latest version: 0.0.2

# @inlay/cache

Every Inlay XRPC component response may include a [`CachePolicy`](https://pdsls.dev/at://did:plc:mdg3w2kpadcyxy33pizokzf3/com.atproto.lexicon.schema/at.inlay.defs#schema:cachePolicy) — a lifetime and a set of invalidation tags:

```json
{
  "life": "hours",
  "tags": [
    // Invalidate me when this record changes
    { "$type": "at.inlay.defs#tagRecord", "uri": "at://did:plc:abc/app.bsky.actor.profile/self" }
  ]
}
```

This package lets you build that policy declaratively. Instead of constructing the object by hand, call `cacheLife` and `cacheTagRecord` anywhere during your handler — including inside async helper functions.

A server runtime can collect these calls and produce the cache policy object.

## Install

```
npm install @inlay/cache
```

## Usage

```ts
import { $ } from "@inlay/core";
import { cacheLife, cacheTagRecord, cacheTagLink } from "@inlay/cache";

async function fetchRecord(uri) {
  cacheTagRecord(uri); // Invalidate me when this record changes
  cacheLife("max");
  const [, , repo, collection, rkey] = uri.split("/");
  const params = new URLSearchParams({ repo, collection, rkey });
  const res = await fetch(
    `https://slingshot.microcosm.blue/xrpc/com.atproto.repo.getRecord?${params}`
  );
  return (await res.json()).value;
}

async function fetchProfileStats(did) {
  cacheLife("hours");
  cacheTagLink(`at://${did}`, "app.bsky.graph.follow"); // Invalidate me on backlinks
  const params = new URLSearchParams({ actor: did });
  const res = await fetch(
    `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?${params}`
  );
  const data = await res.json();
  return { followersCount: data.followersCount };
}

async function ProfileCard({ uri }) {
  const profile = await fetchRecord(uri);
  const did = uri.split("/")[2];
  const stats = await fetchProfileStats(did);

  return $("org.atsui.Stack", { gap: "small" },
    $("org.atsui.Avatar", { src: profile.avatar, did }),
    $("org.atsui.Text", {}, profile.displayName),
    $("org.atsui.Text", {}, `${stats.followersCount} followers`),
  );
}
```

A server runtime could then provide a way to run `ProfileCard` *and* collect its cache policy:

```json
{
  "node": {
    "$": "$",
    "type": "org.atsui.Stack",
    "props": {
      "gap": "small",
      "children": [
        { "$": "$", "type": "org.atsui.Avatar", "props": { "src": "...", "did": "did:plc:ragtjsm2j2vknwkz3zp4oxrd" }, "key": "0" },
        { "$": "$", "type": "org.atsui.Text", "props": { "children": ["Paul Frazee"] }, "key": "1" },
        { "$": "$", "type": "org.atsui.Text", "props": { "children": ["308032 followers"] }, "key": "2" }
      ]
    }
  },
  "cache": {
    "life": "hours",
    "tags": [
      { "$type": "at.inlay.defs#tagRecord", "uri": "at://did:plc:ragtjsm2j2vknwkz3zp4oxrd/app.bsky.actor.profile/self" },
      { "$type": "at.inlay.defs#tagLink", "subject": "at://did:plc:ragtjsm2j2vknwkz3zp4oxrd", "from": "app.bsky.graph.follow" }
    ]
  }
}
```

## Server

There is no prescribed server runtime. Cache functions write to a `Dispatcher` on `Symbol.for("inlay.cache")`. Your server provides the implementation. Minimal example:

```ts
import { AsyncLocalStorage } from "node:async_hooks";
import { serializeTree } from "@inlay/core";
import type { Dispatcher } from "@inlay/cache";

const LIFE_ORDER = ["seconds", "minutes", "hours", "max"];
const cacheStore = new AsyncLocalStorage();

// Install the dispatcher — cache functions will write here
globalThis[Symbol.for("inlay.cache")] = {
  cacheLife(life) { cacheStore.getStore().lives.push(life); },
  cacheTag(tag) { cacheStore.getStore().tags.push(tag); },
} satisfies Dispatcher;

// Run a handler and collect its cache policy
async function runHandler(handler, props) {
  const state = { lives: [], tags: [] };
  const node = await cacheStore.run(state, () => handler(props));
  const life = state.lives.reduce((a, b) =>
    LIFE_ORDER.indexOf(a) < LIFE_ORDER.indexOf(b) ? a : b
  );
  return {
    node: serializeTree(node),
    cache: { life, tags: state.tags },
  };
}

const result = await runHandler(ProfileCard, {
  uri: "at://did:plc:ragtjsm2j2vknwkz3zp4oxrd/app.bsky.actor.profile/self",
});
console.log(JSON.stringify(result, null, 2));
// => { node: { ... }, cache: { life: "hours", tags: [...] } }
```

Installation happens via a global so that coordination doesn't depend on package versioning or hoisting working correctly. Helpers like `fetchRecord` and `fetchProfileStats` can be moved into libraries.

## API

| Function | Description |
|----------|-------------|
| `cacheLife(life)` | Set cache duration. Strictest (shortest) call wins. Values: `"seconds"`, `"minutes"`, `"hours"`, `"max"` |
| `cacheTagRecord(uri)` | Invalidate when this AT Protocol record is created, updated, or deleted |
| `cacheTagLink(subject, from?)` | Invalidate when any record linking to `subject` changes. Optionally restrict to a specific collection |

### Types

- **`Life`** — `"seconds" | "minutes" | "hours" | "max"`
- **`CacheTag`** — `TagRecord | TagLink`
- **`TagRecord`** — `{ $type: "at.inlay.defs#tagRecord", uri: string }`
- **`TagLink`** — `{ $type: "at.inlay.defs#tagLink", subject: string, from?: string }`
- **`Dispatcher`** — interface for the server runtime to implement