Skip to main content
ClaudeWave
Skill3.1k repo starsupdated 2d ago

ai-persistence/build-cloudflare-adapter

Use when a Cloudflare Worker needs TanStack AI chat persistence — writes a chat-persistence.ts into the app against its D1 binding (raw or via Drizzle), plus a Durable Object LockStore. Covers per-request bindings, wrangler config, D1 migrations, and lease-based locks.

Install in Claude Code
Copy
git clone --depth 1 https://github.com/TanStack/ai /tmp/ai-persistence-build-cloudflare-adapter && cp -r /tmp/ai-persistence-build-cloudflare-adapter/packages/ai-persistence/skills/ai-persistence/build-cloudflare-adapter ~/.claude/skills/ai-persistence-build-cloudflare-adapter
Then start a new Claude Code session; the skill loads automatically.

SKILL.md

# Cloudflare Chat Persistence

The deliverable is **one file in the Worker** — `src/lib/chat-persistence.ts` —
exporting a factory that builds a `ChatPersistence` from the request's D1
binding, plus (when the app needs coordination) a Durable Object lock store.
Tables go into the app's existing `migrations/` directory and are applied with
`wrangler d1 migrations apply`.

Do not create a package or a migration runner. Wrangler already tracks applied
migrations; a second bookkeeping table only creates drift.

Read the **Store Reference**
(`docs/persistence/store-reference.md`) for the store contracts, and
**ai-persistence/stores** for the shape rules. This skill covers only
the Cloudflare-specific parts.

## 1. Read the app before writing anything

| Find                   | Where to look                                                                                    | What it decides                                   |
| ---------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------- |
| D1 binding name        | `wrangler.jsonc` `d1_databases[].binding`                                                        | `env.DB` vs `env.AI_STATE` in the factory         |
| How `env` reaches code | the Worker `fetch(request, env)`, or an async-local helper (`getDb()`, `getCloudflareContext()`) | Whether the factory takes `env` or reads a helper |
| Drizzle or raw D1      | `drizzle-orm` in `package.json`, a `src/db/schema.ts`                                            | Which recipe below to follow                      |
| Migrations dir         | `wrangler.jsonc` `migrations_dir`, default `migrations/`                                         | Where the new `.sql` file goes                    |
| Existing table names   | the current migrations / schema                                                                  | Prefix (`chat_*`) so nothing collides             |

## 2. Two independent pieces

```
D1 database      -> messages, runs, interrupts, metadata   (AIPersistence.stores)
Durable Object   -> LockStore                              (withLocks — NOT a store)
```

These do not compose into one object. `AIPersistence.stores` accepts exactly
four keys. Putting `locks` in the map throws
`Unknown AIPersistence store key: locks`; putting it in a `composePersistence`
override throws `Unknown AIPersistence override key: locks`. Both also fail to
type-check. Return
the state persistence from one factory and the lock store from another, then
wire them as two middlewares.

Most apps need only the first piece. Add the Durable Object when other
middleware genuinely needs mutual exclusion across isolates —
`InMemoryLockStore` gives none, because a Worker runs on many isolates at once.

## 3. Bindings are per-request

This is the one rule that separates Cloudflare from every other backend. A D1
binding does not exist at module scope, so `chat-persistence.ts` **must export a
factory**, not a const:

```ts ignore
import { defineAIPersistence } from '@tanstack/ai-persistence'
import type { ChatPersistence } from '@tanstack/ai-persistence'

/** Call inside a request handler — `env` is not available at module scope. */
export function chatPersistence(d1: D1Database): ChatPersistence {
  return defineAIPersistence({
    stores: {
      messages: createMessageStore(d1),
      runs: createRunStore(d1),
      interrupts: createInterruptStore(d1),
      metadata: createMetadataStore(d1),
    },
  })
}
```

Annotate `ChatPersistence` — bare `AIPersistence` is the all-optional bag and
`withPersistence` rejects it. Building it per request is cheap: the stores hold
no state beyond the binding.

## 4. The stores

Two routes, same invariants:

- **Drizzle over D1** — if the app already runs Drizzle, wrap the binding with
  `drizzle(env.DB, { schema })` and follow
  **ai-persistence/build-drizzle-adapter** verbatim (its "if `db` is
  per-request" section is exactly this case). Stop reading here.
- **Raw D1** — implement the four stores against `d1.prepare(sql).bind(...)`:
  `.first()` for `get`, `.all()` for `list*`, `.run()` for writes. D1 speaks
  SQLite, so this mirrors the `node:sqlite` walkthrough in the guide one-for-one;
  everything is already async, so no `Promise.resolve` wrapping.

The invariants are the whole game, whichever route you take:

| Store        | Rule                                                                                                                                                                                               |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `messages`   | `saveThread` is a full replace (`INSERT … ON CONFLICT(thread_id) DO UPDATE`)                                                                                                                       |
| `runs`       | `createOrResume` reads first, else `INSERT … ON CONFLICT DO NOTHING`, then re-reads                                                                                                                |
| `runs`       | `update` on an unknown id is a silent no-op — never throws, never inserts                                                                                                                          |
| `runs`       | `findActiveRun` (required) returns the latest `'running'` run for the thread, else null                                                                                                            |
| `runs`       | `listByThread` (optional) returns every run for the thread `ORDER BY started_at ASC`                                                                                                               |
| `runs`       | `listReclaimable` (optional) returns runs where `status = 'running' AND