diff --git a/.changeset/client-browser-refresh-durability.md b/.changeset/client-browser-refresh-durability.md new file mode 100644 index 000000000..3f65374a6 --- /dev/null +++ b/.changeset/client-browser-refresh-durability.md @@ -0,0 +1,17 @@ +--- +'@tanstack/ai-client': minor +'@tanstack/ai-react': minor +'@tanstack/ai-solid': minor +'@tanstack/ai-vue': minor +'@tanstack/ai-svelte': minor +'@tanstack/ai-angular': minor +'@tanstack/ai-preact': minor +--- + +Add browser-refresh durability to the `persistence` option. + +The client `persistence` adapter now stores one combined record per chat id, the message transcript plus a resume snapshot, so a full page reload restores the conversation, rehydrates any pending interrupt, and rejoins a run that was still streaming (via `joinRun`, when the connection is durability-backed). A bare `UIMessage[]` from an older store is still read for backward compatibility. + +The `persistence` option also accepts an object form, `{ store, messages?: boolean }`. `messages: false` caches only the tiny resume pointer (which run to rejoin, which interrupts are pending), keeping large transcripts off the client, durability rejoin and interrupt restore still work, and the server stays authoritative for history. A bare adapter is shorthand for `{ store, messages: true }`. + +New web storage adapters are exported for this: `localStoragePersistence`, `sessionStoragePersistence`, and `indexedDBPersistence` (plus `StorageUnavailableError` and the `ChatPersistedState` / `ChatStorageAdapter` / `ChatPersistenceConfig` / `ChatPersistenceOption` types). Because durability rides the existing `persistence` option, every framework integration (`react`, `solid`, `vue`, `svelte`, `angular`, `preact`) gets it with no framework-specific code. diff --git a/.changeset/persistence-packages.md b/.changeset/persistence-packages.md new file mode 100644 index 000000000..3bfd0d152 --- /dev/null +++ b/.changeset/persistence-packages.md @@ -0,0 +1,19 @@ +--- +'@tanstack/ai-persistence': minor +'@tanstack/ai-persistence-drizzle': minor +'@tanstack/ai-persistence-prisma': minor +'@tanstack/ai-persistence-cloudflare': minor +--- + +Add server-side persistence for `chat()`: durable thread messages, run records, and interrupts. + +`withPersistence(persistence)` is a chat middleware that stores the conversation transcript, tracks each run's status, and records interrupt state so a paused run (tool approval, client-tool execution, generic interrupt) survives a server restart. Point it at any backend that implements the store contract: + +- `@tanstack/ai-persistence` — the store contracts, the `withPersistence` / `withGenerationPersistence` middleware, and an in-memory reference store (`memoryPersistence`) plus a conformance testkit. +- `@tanstack/ai-persistence-drizzle` — Drizzle-backed stores (SQLite via `node:sqlite`, plus a bring-your-own-schema contract) with migration and schema CLIs. +- `@tanstack/ai-persistence-prisma` — Prisma-backed stores with a models CLI that emits a provider-neutral schema fragment. Works with both Prisma 6 (`prisma-client-js`) and Prisma 7 (`prisma-client`): the client argument is typed structurally, so it accepts a client generated to any output path. +- `@tanstack/ai-persistence-cloudflare` — D1-backed stores (delegating to the Drizzle backend) plus a Durable-Object lock store for cross-instance locking. + +Resume reconstruction is delegated to the chat engine: persistence records interrupts and gates new input on a thread with pending interrupts, while the engine rebuilds the resume tool state from the resume batch and the interrupt bindings carried in the (server-loaded) message history. + +`reconstructChat(persistence, request)` is a server helper that returns a thread's stored messages as a JSON `Response`, so a server-authoritative client can hydrate its transcript on load from a one-line `GET` handler. diff --git a/.changeset/seamless-reload-resume.md b/.changeset/seamless-reload-resume.md new file mode 100644 index 000000000..1573d5572 --- /dev/null +++ b/.changeset/seamless-reload-resume.md @@ -0,0 +1,21 @@ +--- +'@tanstack/ai': minor +'@tanstack/ai-client': minor +'@tanstack/ai-persistence': minor +--- + +Make a mid-stream reload resume the same conversation cleanly. + +- `withPersistence` now persists the pending turn at the start of a run (so a + reload during generation still shows the user's message), stamps each + assistant turn with its stream `messageId`, and accepts + `withPersistence(persistence, { snapshotStreaming: true })` to also persist the + in-progress reply on a throttled interval (`snapshotIntervalMs`, default + `1000`) for partial-output durability. +- `ModelMessage` gains an optional `id`; `modelMessagesToUIMessages` preserves + it, so a hydrated message keeps the same identity as its live stream. +- On reload, the chat client rebuilds an in-flight assistant turn from the + delivery log (replaying from the start and applying the buffered backlog in one + batch) instead of reconciling against the persisted partial, so the reload + shows one clean bubble that catches up and continues rather than a frozen or + duplicated partial. diff --git a/docs/chat/persistence.md b/docs/chat/persistence.md deleted file mode 100644 index 985c8aa2d..000000000 --- a/docs/chat/persistence.md +++ /dev/null @@ -1,153 +0,0 @@ ---- -title: Persistence -id: chat-persistence -order: 5 -description: "Persist chat conversations on the client with TanStack AI — hydrate on load, save on change, and clear on reset using a simple getItem/setItem/removeItem adapter." -keywords: - - tanstack ai - - persistence - - chat history - - localStorage - - indexeddb - - offline - - hydration ---- - -By default a `ChatClient` (and every framework `useChat`/`createChat` wrapper) keeps messages in memory only — reload the page or navigate away and the conversation is gone. The optional **persistence adapter** wires the client to a storage backend so conversations survive reloads, with no manual `initialMessages` + `onFinish` boilerplate. - -This is especially useful for SPAs, Electron apps, and offline-first setups where the client is the source of truth and there's no server managing conversation state. - -## The adapter interface - -A persistence adapter is any object with three methods — the same `getItem`/`setItem`/`removeItem` shape used elsewhere in TanStack AI. Each method may be synchronous or return a `Promise`: - -```typescript -import type { UIMessage } from "@tanstack/ai-client"; - -interface ChatClientPersistence { - getItem: ( - id: string, - ) => - | Array - | null - | undefined - | Promise | null | undefined>; - setItem: (id: string, messages: Array) => void | Promise; - removeItem: (id: string) => void | Promise; -} -``` - -The `id` passed to each method is the client's `id` option. Provide a stable `id` per conversation so the right history is loaded back: - -```typescript -import { ChatClient } from "@tanstack/ai-client"; -import { adapter, myPersistenceAdapter } from "./chat-setup"; - -const client = new ChatClient({ - id: "conversation-123", - connection: adapter, - persistence: myPersistenceAdapter, -}); -``` - -## What the client does for you - -When a `persistence` adapter is provided, `ChatClient`: - -- **Hydrates on construction** — calls `getItem(id)`. If it returns an array, those messages populate the client (overriding `initialMessages`). Async adapters hydrate as soon as the promise resolves, unless you've already started a new conversation in the meantime. -- **Saves on every change** — calls `setItem(id, messages)` whenever the message list changes (new user message, streamed assistant content, tool calls/results, approval responses). Writes are queued so they never overlap or land out of order. -- **Clears on `clear()`** — calls `removeItem(id)` and discards any in-flight stream so a cleared conversation doesn't get repopulated by late chunks. - -When `persistence` is omitted, nothing changes — the client behaves exactly as before. The option is fully backwards compatible. - -Persistence is **best-effort**: if an adapter method throws or rejects, the error is swallowed so storage problems never break the chat. Handle and surface errors inside your adapter if you need to react to them. - -## Framework usage - -Every framework wrapper accepts the same `persistence` option and forwards it to the underlying `ChatClient`: - -```tsx -// React / Preact -import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; -import { myPersistenceAdapter } from "./persistence"; - -const chat = useChat({ - id: "conversation-123", - connection: fetchServerSentEvents("/api/chat"), - persistence: myPersistenceAdapter, -}); -``` - -```ts -// Solid / Vue — same option -import { useChat, fetchServerSentEvents } from "@tanstack/ai-solid"; -import { myPersistenceAdapter } from "./persistence"; - -const chat = useChat({ - id: "conversation-123", - connection: fetchServerSentEvents("/api/chat"), - persistence: myPersistenceAdapter, -}); -``` - -```ts ignore -// Svelte -const chat = createChat({ - id: "conversation-123", - connection: fetchServerSentEvents("/api/chat"), - persistence: myPersistenceAdapter, -}); -``` - -## Example: `localStorage` - -A synchronous adapter backed by `localStorage`. Note that `UIMessage.createdAt` is a `Date`, which `JSON.stringify` turns into a string — revive it on read if you depend on it: - -```typescript -import type { ChatClientPersistence, UIMessage } from "@tanstack/ai-client"; - -const localStoragePersistence: ChatClientPersistence = { - getItem: (id) => { - const raw = window.localStorage.getItem(id); - if (!raw) return null; - const stored: Array = JSON.parse(raw); - return stored.map((message) => ({ - ...message, - createdAt: - typeof message.createdAt === "string" - ? new Date(message.createdAt) - : message.createdAt, - })); - }, - setItem: (id, messages) => { - window.localStorage.setItem(id, JSON.stringify(messages)); - }, - removeItem: (id) => { - window.localStorage.removeItem(id); - }, -}; -``` - -## Example: IndexedDB (async) - -For larger histories or structured queries, back the adapter with an async store such as IndexedDB. The client awaits async methods automatically: - -```typescript -import type { ChatClientPersistence } from "@tanstack/ai-client"; -import { db } from "./db"; - -const indexedDbPersistence: ChatClientPersistence = { - getItem: async (id) => { - const record = await db.conversations.get(id); - return record?.messages; - }, - setItem: async (id, messages) => { - await db.conversations.put({ id, messages, updatedAt: Date.now() }); - }, - removeItem: async (id) => { - await db.conversations.delete(id); - }, -}; -``` - -Any backend works — IndexedDB, SQLite (Electron/Tauri), a remote database, or an in-memory `Map` for tests — as long as it implements the three methods. diff --git a/docs/config.json b/docs/config.json index 592302cbc..00613a105 100644 --- a/docs/config.json +++ b/docs/config.json @@ -174,12 +174,6 @@ "label": "Thinking & Reasoning", "to": "chat/thinking-content", "addedAt": "2026-04-15" - }, - { - "label": "Persistence", - "to": "chat/persistence", - "addedAt": "2026-06-02", - "updatedAt": "2026-07-14" } ] }, @@ -224,7 +218,8 @@ { "label": "Overview", "to": "resumable-streams/overview", - "addedAt": "2026-07-17" + "addedAt": "2026-07-17", + "updatedAt": "2026-07-23" }, { "label": "Advanced", @@ -238,6 +233,71 @@ } ] }, + { + "label": "Persistence", + "children": [ + { + "label": "Overview", + "to": "persistence/overview", + "addedAt": "2026-07-22", + "updatedAt": "2026-07-23" + }, + { + "label": "Chat Persistence", + "to": "persistence/chat-persistence", + "addedAt": "2026-07-22", + "updatedAt": "2026-07-23" + }, + { + "label": "Client Persistence", + "to": "persistence/client-persistence", + "addedAt": "2026-07-22", + "updatedAt": "2026-07-23" + }, + { + "label": "Controls", + "to": "persistence/controls", + "addedAt": "2026-07-22", + "updatedAt": "2026-07-23" + }, + { + "label": "Custom Stores", + "to": "persistence/custom-stores", + "addedAt": "2026-07-22" + }, + { + "label": "SQL Backends", + "to": "persistence/sql-backends", + "addedAt": "2026-07-22" + }, + { + "label": "Drizzle", + "to": "persistence/drizzle", + "addedAt": "2026-07-22" + }, + { + "label": "Prisma", + "to": "persistence/prisma", + "addedAt": "2026-07-22", + "updatedAt": "2026-07-23" + }, + { + "label": "Cloudflare", + "to": "persistence/cloudflare", + "addedAt": "2026-07-22" + }, + { + "label": "Migrations", + "to": "persistence/migrations", + "addedAt": "2026-07-22" + }, + { + "label": "Internals", + "to": "persistence/internals", + "addedAt": "2026-07-22" + } + ] + }, { "label": "Protocol", "children": [ diff --git a/docs/persistence/chat-persistence.md b/docs/persistence/chat-persistence.md new file mode 100644 index 000000000..83011049f --- /dev/null +++ b/docs/persistence/chat-persistence.md @@ -0,0 +1,114 @@ +--- +title: Chat Persistence +id: chat-persistence +--- + +# Chat Persistence + +You want a conversation to outlive a single request: the transcript, whether +each run finished or is still waiting on an interrupt, all still there after the +process restarts. `withPersistence` is a chat middleware that writes that +state to a store you choose, so the server owns an authoritative copy of every +thread. + +## Persist state on the server + +Add the middleware to `chat()` and point it at a backend. Here it is a local +SQLite file via the Drizzle backend: + +```ts group=chat-persistence +import { + chat, + chatParamsFromRequestBody, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { withPersistence } from '@tanstack/ai-persistence' +import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite' + +// One store for the whole process. `migrate: true` applies the bundled schema. +const persistence = sqlitePersistence({ + url: 'file:.data/chat.sqlite', + migrate: true, +}) + +export async function POST(request: Request) { + const params = await chatParamsFromRequestBody(await request.json()) + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages: params.messages, + threadId: params.threadId, + runId: params.runId, + // Forward the resume batch so a thread with pending interrupts continues. + ...(params.resume ? { resume: params.resume } : {}), + middleware: [withPersistence(persistence)], + }) + return toServerSentEventsResponse(stream) +} +``` + +The middleware uses whichever stores the backend provides, no feature flags: + +- `messages` loads and saves the full model-message thread. +- `runs` records running, completed, failed, or interrupted status. +- `interrupts` records pending tool-approval / client-tool / generic waits, and + requires `runs`. +- `locks` is handed to other middleware for cross-worker coordination. + +`migrate: true` is convenient for local development. In production, apply the +bundled migrations through your deployment workflow instead. See +[Migrations](./migrations). + +## Send the full transcript, or none of it + +`withPersistence` follows one rule, the authoritative-history contract: + +- A request with a **non-empty** `messages` array is the full conversation. On + finish it **overwrites** the stored thread. Post the complete transcript, not + a delta, or you replace the stored thread with just the newest message. +- A request with an **empty** `messages` array continues a stored thread. The + middleware loads the stored transcript and the run picks up from there, so the + client does not have to re-send history. + +## What gets persisted, and when + +`withPersistence` writes at three moments so a reload never loses a turn: + +- **At the start of a run** — the pending turn (the just-submitted user + message plus any prior history) is saved immediately, so a reload *during* + generation still shows what the user asked, before the reply exists. +- **On finish** — the complete transcript, including the assistant's terminal + reply. Each assistant turn keeps its stream `messageId`, so a hydrated + message shares identity with its live stream and a mid-stream reload can + resume the *same* message in place rather than duplicating it. +- **Optionally, while streaming** — pass `snapshotStreaming: true` to also + persist the in-progress reply on a throttled interval, so even a crash + mid-generation leaves the partial text in the thread: + + ```ts group=chat-persistence + const streamingMiddleware = [ + withPersistence(persistence, { snapshotStreaming: true }), + ] + ``` + + It defaults off (finish is the authoritative save); enable it to trade extra + writes for partial-output durability. Tune the interval with + `snapshotIntervalMs` (default `1000`). + +## Interrupts survive a restart + +When a run pauses on an interrupt (a tool approval, a client-side tool, a +generic wait), the middleware records it. A later request on that thread must +carry a `resume` batch that answers the pending interrupts before new input is +accepted, otherwise it is rejected, which is why the example above forwards +`params.resume`. The chat engine itself rebuilds the resume state from that +batch and the interrupt bindings in the loaded history, so the persistence layer +only records the interrupts and gates the thread. + +## Where to go next + +- Bring durability to the browser too, so a full page reload restores the + conversation and rejoins an in-flight run: [Client persistence](./client-persistence). +- Pick a backend: [Drizzle](./drizzle), [Prisma](./prisma), + [Cloudflare](./cloudflare), or [your own store](./custom-stores). +- Choose which stores to run: [Controls](./controls). diff --git a/docs/persistence/client-persistence.md b/docs/persistence/client-persistence.md new file mode 100644 index 000000000..68530448d --- /dev/null +++ b/docs/persistence/client-persistence.md @@ -0,0 +1,122 @@ +--- +title: Client Persistence +id: client-persistence +--- + +# Client Persistence + +A `ChatClient` (and every framework `useChat` / `createChat`) keeps messages in +memory, so a reload or a crashed tab loses the whole conversation and any reply +that was still streaming. The `persistence` option fixes that from the browser +side: on reload it repaints the transcript, brings back a pending interrupt, and +rejoins a run that was mid-stream. + +You need this whether or not you have a server: + +- **The browser owns the chat** (SPA, offline-first, no server store). Client + persistence is the only durable copy, so it holds the full transcript. +- **The server owns the chat** (you use [Chat persistence](./chat-persistence)). + The server is the source of truth, but the client still has to come back + instantly on reload: rejoin the in-flight run, restore the pending interrupt, + and show the conversation. Client persistence caches the small resume pointer + that makes that possible and hydrates the transcript from the server. So this + page matters even when history lives on the server, that is the + `messages: false` mode below. + +## Turn it on + +Pass a storage adapter as `persistence` and give the chat a stable `threadId` so +a reload finds the same record: + +```tsx +import { + fetchServerSentEvents, + localStoragePersistence, + useChat, +} from '@tanstack/ai-react' + +function Chat() { + const { messages, sendMessage } = useChat({ + threadId: 'support-chat', + connection: fetchServerSentEvents('/api/chat'), + persistence: localStoragePersistence(), + }) + // ...render messages, call sendMessage(text) +} +``` + +`localStoragePersistence()` needs no type argument and no codec: it defaults to +the chat record shape and a JSON codec. That is the whole opt-in. + +## What a reload restores + +The client stores one record per `threadId`, the transcript plus a small resume +pointer. On the next load `useChat` reads it and: + +- **Repaints the transcript** before the first render, from storage, no network. +- **Rehydrates a pending interrupt**, so an approval prompt comes back exactly as + it was. +- **Rejoins an in-flight run**, if a reply was still streaming when the page + reloaded, so it finishes in place instead of freezing half-done. This one needs + a durability-backed connection (a route that records the stream and exposes a + replay handler); see [Resumable streams](../resumable-streams/overview). + +## Choose what to cache + +`persistence` takes either a bare adapter (cache everything) or +`{ store, messages }` (pull the message lever). + +### Cache everything (default): client-authoritative + +Pass the adapter directly, `persistence: localStoragePersistence()`. The +transcript and the resume pointer both live in the browser. The client owns the +history; the server, if any, mirrors it. Best when the browser is the source of +truth: single-page apps, offline-first, one device, small to moderate +conversations. + +### Resume pointer only: server-authoritative + +Pass the object form, `persistence: { store, messages: false }`, where `store` +is your adapter. Only the tiny resume pointer is cached (which run to rejoin, +which interrupts are pending). Reload durability still works, but the transcript +stays off the client and the server owns history. Best when transcripts are +large (localStorage is synchronous and quota-bound), when the same conversation +must open on another device, or when you simply do not want message content in +the browser. + +The transcript is not in storage, so hydrate it from the server on load: expose a +`GET` endpoint with `reconstructChat(persistence, request)` and read it from a +router loader to seed `initialMessages`. See [Chat persistence](./chat-persistence). + +| Mode | Caches on client | Authoritative history | Reach for it when | +| --- | --- | --- | --- | +| `persistence: store` | transcript + resume pointer | client | SPA / offline, one device, small to moderate history | +| `{ store, messages: false }` | resume pointer only | server | large histories, multi-device, no transcripts in the browser | + +## Choose a storage backend + +Three adapters ship from `@tanstack/ai-client`, re-exported from every framework +package. All share the same shape; they differ in lifetime and encoding. + +| Adapter | Lifetime | Notes | Reach for it when | +| --- | --- | --- | --- | +| `localStoragePersistence` | across reloads and browser restarts | synchronous, ~5MB quota, JSON codec | the default: persist a conversation for next time | +| `sessionStoragePersistence` | one tab, cleared when it closes | same shape as localStorage | a conversation that should not outlive the tab | +| `indexedDBPersistence` | across reloads and restarts | async, structured clone (a `Date` round-trips exactly), room for large data | big transcripts, or values a JSON codec would mangle | + +```tsx +import { indexedDBPersistence } from '@tanstack/ai-react' + +const persistence = indexedDBPersistence() +``` + +Each throws only lazily, per operation, when its backing store is missing (for +example during server-side rendering), so constructing one on the server is safe. + +## Client and server are independent + +Client persistence restores what one browser rendered. Server persistence +([Chat persistence](./chat-persistence)) keeps the authoritative copy for every +user and survives a server restart. They compose: for the combination we +recommend for most apps, and why, see the +[Persistence overview](./overview#what-we-recommend). diff --git a/docs/persistence/cloudflare.md b/docs/persistence/cloudflare.md new file mode 100644 index 000000000..a67ee35b3 --- /dev/null +++ b/docs/persistence/cloudflare.md @@ -0,0 +1,175 @@ +--- +title: Cloudflare Persistence +id: cloudflare +--- + +# Cloudflare Persistence + +`@tanstack/ai-persistence-cloudflare` maps state stores to Cloudflare-native +primitives: + +| Binding | Stores | +| --- | --- | +| D1 | `messages`, `runs`, `interrupts`, `metadata` | +| Durable Objects | `locks` | + +Pass only the bindings you need. The return type contains exactly the stores +those bindings can provide. + +This package persists state. It does not provide a stream-delivery adapter; +stream re-attach / delivery durability is a separate transport-layer feature +([Resumable Streams](../resumable-streams/overview)). + +## Configure bindings + +```jsonc +// wrangler.jsonc +{ + "d1_databases": [ + { + "binding": "AI_STATE", + "database_name": "tanstack-ai-state", + "database_id": "", + "migrations_dir": "migrations" + } + ], + "durable_objects": { + "bindings": [ + { + "name": "AI_LOCKS", + "class_name": "CloudflareLockDurableObject" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["CloudflareLockDurableObject"] + } + ] +} +``` + +Re-export the lock Durable Object from your Worker entry: + +```ts +export { CloudflareLockDurableObject } from '@tanstack/ai-persistence-cloudflare' +``` + +## Create persistence + +```ts ignore +import { cloudflarePersistence } from '@tanstack/ai-persistence-cloudflare' + +interface Env { + AI_STATE: D1Database + AI_LOCKS: DurableObjectNamespace +} + +export function createPersistence(env: Env) { + return cloudflarePersistence({ + d1: env.AI_STATE, + durableObjects: env.AI_LOCKS, + lockOptions: { + leaseDurationMs: 30_000, + retryDelayMs: 50, + }, + }) +} +``` + +D1 stores structured records in SQLite tables. Each lock key is routed to a +Durable Object, which serializes owners and uses leases/alarms for recovery. + +## Use it with chat + +```ts ignore +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { withPersistence } from '@tanstack/ai-persistence' +import { createPersistence } from './persistence' + +interface Env { + AI_STATE: D1Database + AI_LOCKS: DurableObjectNamespace +} + +export default { + async fetch(request: Request, env: Env) { + try { + const params = await chatParamsFromRequest(request) + const persistence = createPersistence(env) + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages: params.messages, + threadId: params.threadId, + runId: params.runId, + ...(params.resume ? { resume: params.resume } : {}), + middleware: [withPersistence(persistence)], + }) + + return toServerSentEventsResponse(stream) + } catch (error) { + if (error instanceof Response) return error + throw error + } + }, +} +``` + +## Apply D1 migrations + +The package exports an ordered `d1Migrations` manifest and a CLI. Copy the SQL +into the directory managed by Wrangler: + +```bash +pnpm exec tanstack-ai-cloudflare-migrations --out migrations +wrangler d1 migrations apply tanstack-ai-state --local +wrangler d1 migrations apply tanstack-ai-state --remote +``` + +Use `--stdout` to print the ordered SQL. Existing divergent files are not +overwritten unless `--force` is passed. Commit the copied migrations and apply +them before deploying code that uses the stores. + +Programmatic tooling can read the same manifest: + +```ts +import { d1Migrations } from '@tanstack/ai-persistence-cloudflare' + +for (const migration of d1Migrations) { + console.log(migration.filename) +} +``` + +## Override selected stores + +Use Cloudflare as the base and replace only application-owned stores: + +```ts ignore +import { composePersistence } from '@tanstack/ai-persistence' +import { createPersistence } from './persistence' +import { customInterrupts, customRuns } from './stores' + +interface Env { + AI_STATE: D1Database + AI_LOCKS: DurableObjectNamespace +} + +export function createComposedPersistence(env: Env) { + return composePersistence(createPersistence(env), { + overrides: { + interrupts: customInterrupts, + runs: customRuns, + }, + }) +} +``` + +D1 continues to own messages and metadata, and Durable Objects own locks. +Cross-backend transactions are not added by composition; design retries and +consistency explicitly. diff --git a/docs/persistence/controls.md b/docs/persistence/controls.md new file mode 100644 index 000000000..1638fed23 --- /dev/null +++ b/docs/persistence/controls.md @@ -0,0 +1,74 @@ +--- +title: Persistence Controls +id: controls +--- + +# Persistence Controls + +Persistence has no feature flags. What you persist is decided by which stores +the backend provides, and you compose backends per store. Supply only the +stores your workflow needs. + +## What each store gives you + +| Requirement | Store | +| --- | --- | +| Authoritative server transcript | `messages` | +| Run status and usage | `runs` | +| Durable approvals or human input | `interrupts` (requires `runs`) | +| App or integration checkpoints | `metadata` | +| Cross-worker coordination | `locks` | + +`withPersistence(persistence)` and `withGenerationPersistence(persistence)` +inspect the stores that are present. Store presence is the whole capability +selection mechanism. + +## Compose and override stores + +`composePersistence` takes the base backend first and an overrides object +second. Here it starts from the in-memory reference backend and swaps in custom +`interrupts` / `runs` stores: + +```ts +import { composePersistence, memoryPersistence } from '@tanstack/ai-persistence' +// Your own store implementations of the InterruptStore / RunStore contracts. +import { interruptStore, runStore } from './stores' + +const persistence = composePersistence(memoryPersistence(), { + overrides: { + interrupts: interruptStore, + runs: runStore, + }, +}) +``` + +Each override is independent: + +| Override value | Result | +| --- | --- | +| key omitted | Inherit the base store. | +| `undefined` | Inherit the base store. | +| a store object | Replace that store only. | +| `false` | Remove that store. | + +```ts +import { composePersistence, memoryPersistence } from '@tanstack/ai-persistence' + +// Drop the locks store entirely; the resulting type has no `locks` key. +const withoutLocks = composePersistence(memoryPersistence(), { + overrides: { locks: false }, +}) +``` + +Unknown store names fail type checking, and are also rejected at runtime when +values arrive from untyped JavaScript. + +## Valid store combinations + +`interrupts` requires `runs`: an interrupt record is scoped to a run, so +`withPersistence` rejects a persistence object that has `interrupts` without +`runs`. Everything else is independent, add stores as the workflow needs them. + +To define a partial backend directly rather than by composing, use +`defineAIPersistence({ stores: { ... } })` and pass only the stores you have. +See [Custom stores](./custom-stores) for the store contracts. diff --git a/docs/persistence/custom-stores.md b/docs/persistence/custom-stores.md new file mode 100644 index 000000000..cf5654a9e --- /dev/null +++ b/docs/persistence/custom-stores.md @@ -0,0 +1,161 @@ +--- +title: Custom Stores +id: custom-stores +--- + +# Custom Persistence Stores + +Implement custom stores when your infrastructure is not covered by the +packaged backends or when selected data must remain in an application-owned +database. + +## Define the stores you provide + +```ts +import { defineAIPersistence } from '@tanstack/ai-persistence' +import { messages, runs } from './stores' + +export const persistence = defineAIPersistence({ + stores: { messages, runs }, +}) +``` + +`defineAIPersistence` preserves the exact store keys in the type and rejects +unknown keys at runtime. Middleware behavior follows the keys that exist. + +## Store interfaces + +Each interface below is the public contract from `@tanstack/ai-persistence`. +Implement only the stores you need. + +### Messages + +```ts +import type { ModelMessage } from '@tanstack/ai' + +interface MessageStore { + loadThread(threadId: string): Promise> + saveThread( + threadId: string, + messages: Array, + ): Promise +} +``` + +`saveThread` receives the full authoritative model-message history, not a +delta. `loadThread` returns `[]` (never `null`) for a thread that was never +saved. + +### Runs + +```ts +import type { RunRecord } from '@tanstack/ai-persistence' + +interface RunStore { + createOrResume(input: { + runId: string + threadId: string + status?: 'running' | 'completed' | 'failed' | 'interrupted' + startedAt: number + }): Promise + update( + runId: string, + patch: Partial< + Pick + >, + ): Promise + get(runId: string): Promise +} +``` + +Implement `createOrResume` idempotently: a second call for an existing `runId` +returns the stored record unchanged, which is what makes resuming a run safe. +`update` against an unknown `runId` is a no-op. Retries may repeat the same run +id. + +### Interrupts + +```ts +import type { InterruptRecord } from '@tanstack/ai-persistence' + +interface InterruptStore { + create(record: Omit): Promise + resolve(interruptId: string, response?: unknown): Promise + cancel(interruptId: string): Promise + get(interruptId: string): Promise + list(threadId: string): Promise> + listPending(threadId: string): Promise> + listByRun(runId: string): Promise> + listPendingByRun(runId: string): Promise> +} +``` + +`create` accepts a record without `status`/`resolvedAt` so every interrupt is +born `'pending'`; it is insert-if-absent, so a duplicate `create` never clobbers +an already-resolved interrupt. The `list*` methods return records ordered by +`requestedAt` ascending. An `interrupts` store requires a `runs` store when used +with chat persistence. + +### Metadata + +```ts +interface MetadataStore { + get(scope: string, key: string): Promise + set(scope: string, key: string, value: unknown): Promise + delete(scope: string, key: string): Promise +} +``` + +Namespaces and value schemas are application-owned. `(scope, key)` is the +composite identity. Because a stored `null` is indistinguishable from absence at +the type level, wrap a value you must persist as `null` (e.g. `{ value: null }`). + +### Locks + +`LockStore` comes from `@tanstack/ai-persistence`. Use it to serialize work that +may run on multiple workers. A lock implementation should use leases or another +recovery mechanism so a crashed owner cannot block forever. `withLock` passes an +`AbortSignal` to the critical section. Lease-backed implementations abort that +signal when ownership can no longer be guaranteed; callbacks must stop starting +external mutations and pass the signal to cancellable dependencies. The package +ships an in-process `InMemoryLockStore` for single-process use. + +## Example message store + +```ts +import type { MessageStore } from '@tanstack/ai-persistence' +import type { ModelMessage } from '@tanstack/ai' + +const threads = new Map>() + +export const messages: MessageStore = { + async loadThread(threadId) { + return [...(threads.get(threadId) ?? [])] + }, + async saveThread(threadId, nextMessages) { + threads.set(threadId, [...nextMessages]) + }, +} +``` + +For durable infrastructure, preserve the same semantics with database +transactions, conditional writes, or stable idempotency keys. + +## Override selected packaged stores + +```ts ignore +import { composePersistence } from '@tanstack/ai-persistence' +import { cloudflarePersistence } from '@tanstack/ai-persistence-cloudflare' +import { interrupts, runs } from './stores' + +export function createPersistence(state: D1Database) { + const base = cloudflarePersistence({ d1: state }) + return composePersistence(base, { + overrides: { interrupts, runs }, + }) +} +``` + +Only those two stores move to the custom database; D1 still owns messages and +metadata. Composition does not create a transaction across those systems; +design related writes accordingly. diff --git a/docs/persistence/drizzle.md b/docs/persistence/drizzle.md new file mode 100644 index 000000000..806587aca --- /dev/null +++ b/docs/persistence/drizzle.md @@ -0,0 +1,164 @@ +--- +title: Persistence with Drizzle +id: drizzle +--- + +# Persistence with Drizzle + +`@tanstack/ai-persistence-drizzle` supports SQLite-family Drizzle databases. +It has two entry points: + +- the package root accepts an already-created, migrated `DrizzleSqliteDb` and is + safe to import in edge runtimes; +- `/sqlite` is a Node-only convenience factory built on `node:sqlite`. + +There is no dialect option. For MySQL, PostgreSQL, or another Drizzle dialect, +implement the `AIPersistence` stores for that database or use the Prisma +backend. + +## Node SQLite + +```ts +import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite' + +export const persistence = sqlitePersistence({ + url: 'file:.tanstack-ai/state.sqlite', + migrate: true, +}) +``` + +`url` may be `:memory:`, a filesystem path, or a `file:`-prefixed path. +`migrate: true` applies the bundled migrations before creating stores. Prefer +deployment-time migrations in production. + +## Bring your own SQLite Drizzle database + +```ts ignore +import { drizzle } from 'drizzle-orm/d1' +import { drizzlePersistence, schema } from '@tanstack/ai-persistence-drizzle' + +export function createPersistence(state: D1Database) { + const db = drizzle(state, { schema }) + return drizzlePersistence(db) +} +``` + +The root entry does not import Node built-ins and works with Cloudflare D1 and +other SQLite-compatible Drizzle drivers. The application owns connection +lifecycle and migration timing. + +## Use the middleware + +```ts +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { withPersistence } from '@tanstack/ai-persistence' +import { persistence } from './persistence' + +export async function POST(request: Request) { + const params = await chatParamsFromRequest(request) + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages: params.messages, + threadId: params.threadId, + runId: params.runId, + ...(params.resume ? { resume: params.resume } : {}), + middleware: [withPersistence(persistence)], + }) + + return toServerSentEventsResponse(stream) +} +``` + +`drizzlePersistence` provides all state stores except `locks`: this backend has +no distributed lock, so consumers that need one fall back to an in-process +lock. When multiple workers must share a lock service, use `composePersistence` +to add a distributed `locks` implementation — for example the Cloudflare +Durable Object lock from `@tanstack/ai-persistence-cloudflare`. + +## Get the migrations + +The package exports the ordered `sqliteMigrations` manifest: + +```ts +import { sqliteMigrations } from '@tanstack/ai-persistence-drizzle' + +for (const migration of sqliteMigrations) { + console.log(migration.id, migration.filename) +} +``` + +Or copy canonical SQL files with the CLI: + +```bash +pnpm exec tanstack-ai-drizzle-migrations --out migrations/tanstack-ai +``` + +Use `--stdout` to print the SQL. The CLI refuses to replace a divergent file +unless `--force` is passed. Commit the copied files and apply them using your +normal SQLite, D1, or Drizzle deployment workflow. + +## Schema ownership + +The exported `schema` contains `messages`, `runs`, `interrupts`, and +`metadata`. Application tables may live beside these tables in the same +database. + +## Own the schema + +If your project already uses drizzle-kit, you can own the TanStack AI schema +outright instead of applying the bundled SQL. Emit the schema module into your +project: + +```bash +pnpm exec tanstack-ai-drizzle-schema --out src/db +``` + +This writes `src/db/tanstack-ai-schema.ts` — a regular Drizzle schema file that +imports from **your** installed `drizzle-orm`. The CLI refuses to replace a +divergent file unless `--force` is passed; `--stdout` prints the module instead. + +Add the file to your drizzle-kit schema paths so your own migration journal +owns the DDL: + +```ts ignore +import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + dialect: 'sqlite', + schema: ['./src/db/schema.ts', './src/db/tanstack-ai-schema.ts'], + out: './drizzle', +}) +``` + +Then pass the schema back so the runtime reads and writes through your copy: + +```ts +import { drizzlePersistence } from '@tanstack/ai-persistence-drizzle' +import { schema } from './tanstack-ai-schema' +import { db } from './db' + +export const persistence = drizzlePersistence(db, { schema }) +``` + +Because the runtime operates on the table objects you pass, the file is truly +yours to shape: + +- **Rename tables and columns**, or drop the explicit column names and rely on + your drizzle `casing` configuration — the stores read database names from + your objects, so the generated SQL follows your conventions. +- **Add app-owned columns** — for example a `userId` column on `messages` to + scope threads to users. Keep added columns nullable or defaulted so the + store inserts succeed; the TanStack AI stores never read or write them. +- **Keep the contract columns** with their data shapes. The + `TanstackAiSqliteSchema` type enforces the shapes at compile time, and + `drizzlePersistence` validates the tables and columns exist at construction. + +When you own the schema this way, migrations flow entirely through your +drizzle-kit journal — package upgrades that change the schema surface as +drizzle-kit diffs when you update the emitted file. Don't mix this with the +bundled SQL migrations: pick one DDL owner per database. diff --git a/docs/persistence/internals.md b/docs/persistence/internals.md new file mode 100644 index 000000000..7aa96c4c7 --- /dev/null +++ b/docs/persistence/internals.md @@ -0,0 +1,105 @@ +--- +title: Persistence Internals +id: internals +--- + +# Persistence Internals + +This page describes the server-side contracts between the persistence +middleware and the state stores. + +## Separate boundaries + +Server state persistence is one of three boundaries that intentionally share no +code: + +- **Server state** (this page): `AIPersistence` stores driven by the middleware + lifecycle, the authoritative record. +- **Client hydration**: the browser restores a rendered conversation, a separate + concern covered in [Client persistence](./client-persistence). +- **Stream delivery**: replaying an in-flight SSE response, + [Resumable Streams](../resumable-streams/overview). + +State middleware never mutates chunks to add delivery offsets, and it stores +server event state, not the client's rendered messages. + +## Chat middleware lifecycle + +`withPersistence(persistence)` derives a plan from store presence: + +1. `setup` provides persistence, interrupt, and lock capabilities when their + stores exist. +2. `onConfig` creates or resumes the run, loads pending interrupts, and + validates the request's resume batch against them, then merges stored + messages into the request when the request carries no history. +3. `onChunk` reacts only to a `RUN_FINISHED` interrupt outcome by committing + the accepted resumes, storing the new interrupts, marking the run + interrupted, and saving messages. +4. `onFinish`, `onError`, and `onAbort` terminalize the run record. + +Accepted resumes are committed (interrupts marked resolved/cancelled) only once +the run reaches a successful boundary, so a provider failure or abort between +accepting a resume and reaching that boundary leaves the interrupt pending and a +retry with the same resume succeeds. The canonical AG-UI chunk stream remains +unchanged; persistence does not create a second event stream. + +When a request carries a non-empty `messages` array it is treated as the full +authoritative history and, on finish, overwrites the stored thread. To continue +a stored thread without resending history, pass an empty `messages` array — the +stored transcript is loaded and used. + +## Generation middleware lifecycle + +`withGenerationPersistence(persistence)` records the run: `onStart` creates or +resumes the run record, and `onFinish`, `onError`, and `onAbort` terminalize +it. Durable media storage (artifact metadata plus blob bytes) is a follow-up +feature. + +## Composition semantics + +```ts +import { + composePersistence, + memoryPersistence, +} from '@tanstack/ai-persistence' + +const base = memoryPersistence() +const replacement = base.stores.messages + +const result = composePersistence(base, { + overrides: { + messages: replacement, + metadata: undefined, + locks: false, + }, +}) +``` + +- `messages` is replaced. +- `metadata` is inherited because the override is `undefined`. +- `locks` is removed. +- every omitted store is inherited. + +Composition copies the store map and does not mutate or dispose either input. +The return type calculates which keys are required, optional, replaced, or +removed. Unknown store keys are rejected statically and by runtime validation. + +Middleware adds capability validation: + +- chat rejects `interrupts` without `runs`. + +The runtime checks are required because JavaScript, configuration loading, and +explicitly widened types can bypass static guarantees. + +## Backend ownership + +Packaged backends own resources differently: + +- Drizzle accepts a migrated SQLite-family database; its root import is + edge-safe. The `/sqlite` entry creates a Node SQLite connection. +- Prisma accepts the application's generated and migrated client. +- Cloudflare maps D1 to structured stores and Durable Objects to locks. + +`composePersistence` does not add distributed transactions. When related +stores use different systems, adapter authors must define retry, +idempotency, and consistency behavior. diff --git a/docs/persistence/migrations.md b/docs/persistence/migrations.md new file mode 100644 index 000000000..f68d64f1b --- /dev/null +++ b/docs/persistence/migrations.md @@ -0,0 +1,91 @@ +--- +title: Persistence Migrations +id: migrations +--- + +# Persistence Migrations + +Migration ownership depends on the backend. Apply schema changes before +deploying code that reads or writes the corresponding stores. + +## Drizzle SQLite + +The Drizzle package bundles ordered canonical SQLite migrations. Copy them into +your repository: + +```bash +pnpm exec tanstack-ai-drizzle-migrations --out migrations/tanstack-ai +``` + +Or print the ordered SQL: + +```bash +pnpm exec tanstack-ai-drizzle-migrations --stdout +``` + +The CLI preserves existing identical files and refuses to overwrite divergent +files without `--force`. Apply the copied SQL using your normal SQLite, +Drizzle, or D1 deployment process. + +For local Node development, the `/sqlite` factory can apply the same manifest: + +```ts +import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite' + +const persistence = sqlitePersistence({ + url: 'file:.tanstack-ai/state.sqlite', + migrate: true, +}) +``` + +Avoid request-time migrations in production. + +Projects that already run drizzle-kit can skip the bundled SQL entirely: emit +the schema module with `tanstack-ai-drizzle-schema`, add it to your drizzle-kit +schema paths, and let your own journal generate the DDL. See +[Own the schema](./drizzle#own-the-schema). Pick one DDL owner per database — +bundled SQL or your journal, not both. + +## Cloudflare D1 + +The Cloudflare package provides D1-specific migration assets: + +```bash +pnpm exec tanstack-ai-cloudflare-migrations --out migrations +wrangler d1 migrations apply tanstack-ai-state --remote +``` + +It also exports `d1Migrations` for programmatic tooling. Durable Object locks +do not use the D1 table migration set; configure their bindings and Durable +Object migration tags in Wrangler. + +## Prisma + +Prisma ships a provider-neutral models fragment, then delegates SQL generation +to your application: + +```bash +pnpm exec tanstack-ai-prisma-models --out prisma/schema +pnpm prisma migrate dev --name add-tanstack-ai-persistence +pnpm prisma generate +pnpm prisma migrate deploy +``` + +The copied fragment contains no datasource or generator. Keep those in your +application schema and commit the native migration Prisma creates for your +provider. + +## Custom stores + +Custom `AIPersistence` adapters own their schema and migrations entirely. +Maintain compatibility with the public store records and method semantics; +TanStack AI does not inspect your table layout. + +## Upgrade discipline + +1. Read the package release notes for schema changes. +2. Refresh copied assets in a reviewable branch. +3. Inspect the diff rather than using `--force` blindly. +4. Back up production state where required. +5. Apply migrations before deploying code that depends on them. +6. Keep rollback and partial-deployment behavior explicit. diff --git a/docs/persistence/overview.md b/docs/persistence/overview.md new file mode 100644 index 000000000..f97628159 --- /dev/null +++ b/docs/persistence/overview.md @@ -0,0 +1,249 @@ +--- +title: Persistence Overview +id: overview +description: "How durability and persistence fit together in TanStack AI: keep a stream alive through a dropped connection, restore a conversation after a reload, and keep an authoritative server record. Learn the two layers and when to pick each." +keywords: + - persistence + - durability + - resumable streams + - rehydrate conversation + - page reload + - server authoritative + - client authoritative +--- + +# Durability and Persistence + +Three things can go wrong with an AI chat, and they have different fixes: + +1. The connection drops mid-answer. The user watched half a reply appear, then the socket died. You don't want to re-run the model and pay for it twice. +2. The user reloads the page. The whole conversation is gone, because it only lived in memory. +3. The user opens the app on another device, or your server restarts. There is no record of the conversation anywhere durable. + +TanStack AI solves these with two independent layers. You can use either alone or both together. + +## The two layers + +| Layer | Answers | Lives | Docs | +| --- | --- | --- | --- | +| **Delivery durability** | "how do I reconnect to a stream that's still running?" | a per-run log, keyed by `runId` | [Resumable Streams](../resumable-streams/overview) | +| **State persistence** | "what is the conversation, and is it still there later?" | a durable store (client and/or server) | this section | + +They share no code and solve different problems. Delivery durability replays a live byte stream so a dropped connection resumes exactly where it stopped. State persistence stores the conversation itself, so it survives a reload or exists on another device. A replayable stream is not a saved conversation, and a saved conversation is not a live stream. Real apps usually want both. + +## State persistence has two halves + +Persistence runs on the client, the server, or both. They are independent, and they answer different questions. + +| Half | Stores | Survives | Use it for | +| --- | --- | --- | --- | +| **Client** ([Client persistence](./client-persistence)) | the transcript + a resume pointer, in `localStorage` / `IndexedDB` | a page reload in that browser | instant restore on reload, SPA / offline apps | +| **Server** ([Chat persistence](./chat-persistence)) | messages, run status, interrupts, in SQL / D1 / your store | a server restart, and reaches every device | multi-device, audit, durable approvals | + +A minimal server setup adds one middleware to `chat()`: + +```ts +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { withPersistence } from '@tanstack/ai-persistence' +import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite' + +const persistence = sqlitePersistence({ + url: 'file:.tanstack-ai/state.sqlite', + migrate: true, +}) + +export async function POST(request: Request) { + const params = await chatParamsFromRequest(request) + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages: params.messages, + threadId: params.threadId, + runId: params.runId, + ...(params.resume ? { resume: params.resume } : {}), + middleware: [withPersistence(persistence)], + }) + return toServerSentEventsResponse(stream) +} +``` + +The minimal client setup adds one option to `useChat`: + +```tsx +import { + fetchServerSentEvents, + localStoragePersistence, + useChat, +} from '@tanstack/ai-react' + +function Chat() { + const { messages, sendMessage } = useChat({ + threadId: 'support-chat', + connection: fetchServerSentEvents('/api/chat'), + persistence: localStoragePersistence(), + }) + // ... +} +``` + +## Who owns the history: client or server + +When both halves are on, one rule decides which copy is authoritative, and you pick it per turn by what the client sends as `messages`: + +- **Non-empty `messages`** means "this is the full history." On finish the server overwrites its stored thread with it. The client stays authoritative; the server mirrors. +- **Empty `messages`** means "continue from your own copy." The server loads its stored transcript and runs from there. The server is authoritative; the client is a cache. + +That single rule lets the two copies coexist without a merge conflict. Two postures come out of it: + +- **Client-authoritative**: keep sending the full transcript. `localStorage` is the truth, the server store is a durable backup. Closest to a pure SPA. +- **Server-authoritative**: send empty `messages` and let the server own history. The same thread then opens identically on another device, or after the browser cache is cleared. + +## What happens on a page reload + +On load, `useChat` reads the client record and acts on what it finds: + +1. **The run had finished.** The record has the transcript, no resume pointer. The conversation paints instantly from storage. No network. (client persistence alone) +2. **The run was paused on an interrupt.** The resume pointer carries the pending interrupts. The transcript paints and the approval UI comes back exactly as it was. (client persistence alone) +3. **The run was still streaming.** The transcript paints from storage, then the client rejoins the live run through the durability log and the reply finishes in place. This is the one case that needs **both** layers: persistence supplies the transcript and the `runId`, durability replays the rest. (client persistence + delivery durability) + +A dropped connection while the page is still open is simpler: delivery durability reconnects on its own, no persistence needed. Persistence matters once the page itself is gone. + +If you run server-authoritative with the transcript kept off the client (see [Client persistence](./client-persistence)), the reload paints from a server read instead of `localStorage`. The delivery log cannot supply that history: it holds one run, not the whole thread. + +## When to pick each + +| You want | Turn on | +| --- | --- | +| A dropped connection to resume the same answer | Delivery durability ([Resumable Streams](../resumable-streams/overview)) | +| The conversation to still be there after a reload | [Client persistence](./client-persistence) | +| Reload durability without caching big histories client-side | Client persistence with `{ store, messages: false }` | +| The same conversation on another device, or after a server restart | Server persistence ([Chat persistence](./chat-persistence)) | +| Pause for a human approval and resume it later, durably | Server persistence with an `interrupts` store | +| A mid-stream reload to pick up the live answer | Client persistence + delivery durability together | + +Most production chat apps end up with all three: delivery durability on the route, client persistence for instant reload, and server persistence as the record of record. + +## What we recommend + +For a real multi-user app, one combination beats the rest: + +1. **Client: cache only the resume pointer** with `persistence: { store, messages: false }`. The browser holds a few bytes (which run to rejoin, which interrupts are pending), never the transcript. +2. **Server: `withPersistence`** owns the authoritative history, run status, and durable interrupts. +3. **One `GET` endpoint that does two jobs**: rehydrate the conversation from the store, and resume an in-flight durable stream. + +The server route: + +```ts +import { + chat, + chatParamsFromRequest, + memoryStream, + resumeServerSentEventsResponse, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { + reconstructChat, + withPersistence, +} from '@tanstack/ai-persistence' +import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite' + +const persistence = sqlitePersistence({ + url: 'file:.tanstack-ai/state.sqlite', + migrate: true, +}) + +export async function POST(request: Request) { + const params = await chatParamsFromRequest(request) + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages: params.messages, + threadId: params.threadId, + runId: params.runId, + ...(params.resume ? { resume: params.resume } : {}), + middleware: [withPersistence(persistence)], + }) + return toServerSentEventsResponse(stream, { + durability: { adapter: memoryStream(request) }, + }) +} + +export function GET(request: Request): Response | Promise { + const durability = memoryStream(request) + // In-flight run: the resume offset arrives via the Last-Event-ID header or + // ?offset, and the run id via the X-Run-Id header or ?runId, so ask the + // adapter with resumeFrom() instead of sniffing query params. Replay the log + // so a reload finishes the answer. + if (durability.resumeFrom() !== null) { + return resumeServerSentEventsResponse({ adapter: durability }) + } + // Otherwise rehydrate the conversation from the durable store. `reconstructChat` + // reads `?threadId` and returns the stored messages as JSON. + return reconstructChat(persistence, request) +} +``` + +The client caches only the pointer and loads history from that `GET`: + +```tsx +import { + fetchServerSentEvents, + localStoragePersistence, + useChat, +} from '@tanstack/ai-react' + +const store = localStoragePersistence() + +function Chat() { + const { messages, sendMessage } = useChat({ + threadId: 'support-chat', + connection: fetchServerSentEvents('/api/chat'), + persistence: { store, messages: false }, + }) + // On mount, fetch GET /api/chat?threadId=support-chat and seed the thread + // (a router loader is the natural place). The resume pointer in localStorage + // then rejoins any run that was still streaming. +} +``` + +A mid-stream reload does both jobs, and they do not collide because they are two +separate requests to the same `GET`: the loader fetches history (`?threadId`, +the reconstruct branch) and seeds it as `initialMessages`, and the client +separately calls `joinRun` (`?offset=-1&runId`, the resume branch) to finish the +live run. The `if` in the handler routes each; one request is never asked to do +both. The replayed run's messages merge into the seeded history by message id, +so nothing is duplicated or lost. + +Why this wins over the alternatives: + +- **One source of truth.** History lives on the server, so there is no client/server copy to drift or reconcile. The same conversation opens on any device and survives a server restart. +- **A cheap client.** The browser never parses or stores a long transcript, so there is no `localStorage` quota or startup-parse cost, even for huge threads. +- **Full reload durability anyway.** The tiny resume pointer is enough to rejoin a mid-stream run and restore pending interrupts instantly, so dropping the transcript costs nothing on reload. +- **No wasted work.** The `GET` reuses the same route as the durable-stream resume, and `loadThread` returns ready-made messages instead of replaying a stream to reconstruct them. + +Client-only persistence can't do multi-device and bloats storage. Caching everything client-side duplicates the source of truth. Server-only without the resume pointer can't rejoin a live run after a reload without a round-trip. This combination avoids all three. + +## The store contract + +Server persistence is a set of stores. Middleware activates behavior from whichever stores are present, there is no separate enable list. + +| Store | Purpose | +| --- | --- | +| `messages` | Authoritative model-message history per thread. | +| `runs` | Run status, timing, errors, and usage. | +| `interrupts` | Pending, resolved, or cancelled human/tool waits (needs `runs`). | +| `metadata` | App and integration key/value state. | +| `locks` | Cross-worker coordination. | + +## Where to go next + +- [Chat persistence](./chat-persistence): the server middleware, the authoritative-history contract, and durable interrupts. +- [Client persistence](./client-persistence): client reload restore, the `messages` lever, storage backends, and mid-stream rejoin. +- [Controls](./controls): compose backends per store and choose which stores to run. +- Backends: [Drizzle](./drizzle), [Prisma](./prisma), [Cloudflare](./cloudflare), or your own [Custom stores](./custom-stores). +- [Resumable streams](../resumable-streams/overview): the delivery-durability layer in full. +- [Internals](./internals): the middleware lifecycle and composition mechanics behind every backend. diff --git a/docs/persistence/prisma.md b/docs/persistence/prisma.md new file mode 100644 index 000000000..82be41a34 --- /dev/null +++ b/docs/persistence/prisma.md @@ -0,0 +1,165 @@ +--- +title: Persistence with Prisma +id: prisma +--- + +# Persistence with Prisma + +`@tanstack/ai-persistence-prisma` accepts your generated and migrated +`PrismaClient`. The package provides a provider-neutral models fragment, not a +datasource, generator, connection URL, or prebuilt SQL migration. + +## Prisma 6 and 7 + +Both major versions are supported. `prismaPersistence` types its argument +structurally, so it does not matter where your client comes from — it only +reads the model delegates off it at runtime, and the delegate query API is the +same across versions. + +- **Prisma 6** (`prisma-client-js` generator): the client is generated into + `node_modules`, so you `import { PrismaClient } from '@prisma/client'`. +- **Prisma 7** (`prisma-client` generator): the client is generated to the + `output` path you configure and is no longer exported from `@prisma/client`, + so you import it from that path, e.g. + `import { PrismaClient } from './generated/prisma/client'`. + +The samples below use the Prisma 6 import; on Prisma 7 swap it for your +generated output path. Everything else is identical. + +## Copy the models fragment + +```bash +pnpm exec tanstack-ai-prisma-models --out prisma/schema +``` + +This writes `tanstack-ai.prisma`, containing only the TanStack AI models. Point +Prisma at that multi-file schema directory alongside your application's +datasource, generator, and models. + +Use `--stdout` to inspect the fragment. The CLI refuses to overwrite a +divergent file unless `--force` is passed. + +The same asset is available programmatically: + +```ts +import { + prismaModels, + prismaModelsFilename, +} from '@tanstack/ai-persistence-prisma' + +console.log(prismaModelsFilename) +console.log(prismaModels) +``` + +## Generate a native migration + +Use Prisma's normal workflow for your selected provider: + +```bash +pnpm prisma migrate dev --name add-tanstack-ai-persistence +pnpm prisma generate +``` + +Deploy the resulting application-owned migrations normally: + +```bash +pnpm prisma migrate deploy +``` + +This lets Prisma generate provider-specific SQL and integrate the models with +the rest of your schema history. + +## Create persistence + +```ts ignore +import { PrismaClient } from '@prisma/client' +import { prismaPersistence } from '@tanstack/ai-persistence-prisma' + +const prisma = new PrismaClient() +export const persistence = prismaPersistence(prisma) +``` + +The adapter provides messages, runs, interrupts, and metadata. The application +owns client connection and shutdown lifecycle. + +## Use it with chat + +```ts +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { withPersistence } from '@tanstack/ai-persistence' +import { persistence } from './persistence' + +export async function POST(request: Request) { + const params = await chatParamsFromRequest(request) + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages: params.messages, + threadId: params.threadId, + runId: params.runId, + ...(params.resume ? { resume: params.resume } : {}), + middleware: [withPersistence(persistence)], + }) + + return toServerSentEventsResponse(stream) +} +``` + +`prismaPersistence` provides no `locks` store: this backend has no distributed +lock, so consumers that need one fall back to an in-process lock. Use +`composePersistence` to add a distributed `locks` implementation — for example +the Cloudflare Durable Object lock from `@tanstack/ai-persistence-cloudflare` — +or to route selected stores to another system. Composition does not add a +transaction across multiple backends. + +## Model layout + +The fragment maps four persisted store contracts to `Message`, `Run`, +`Interrupt`, and `Metadata` models. IDs and timestamps use portable Prisma +types so the application can generate migrations for its chosen provider. + +## Rename the models + +The fragment is yours once copied, and its model names are generic — an +application often already has a `Message` or `Run` model. Rename the TanStack +AI models freely and map each store to the renamed client delegate: + +```prisma +/// Renamed from `Message` to avoid a collision. +model ChatMessage { + threadId String @id @map("thread_id") + messagesJson String @map("messages_json") + + @@map("messages") +} +``` + +```ts ignore +import { PrismaClient } from '@prisma/client' +import { prismaPersistence } from '@tanstack/ai-persistence-prisma' + +const prisma = new PrismaClient() + +export const persistence = prismaPersistence(prisma, { + models: { messages: 'chatMessage' }, +}) +``` + +Map values are the camelCase client accessor names (`prisma.chatMessage`), not +the PascalCase model names. Unmapped stores keep their default names +(`message`, `run`, `interrupt`, `metadata`), and `prismaPersistence` throws a +`PrismaModelError` naming every store whose delegate cannot be found. + +What stays fixed is the client-level field surface: keep the fragment's field +names and types, and the default composite unique alias `scope_key` on the +metadata model. Everything else is yours: + +- **Database names** — table and column names are already governed by + `@@map` / `@map` in your copy; change them without touching the runtime. +- **Extra app-owned fields** — for example a `userId` on the messages model to + scope threads to users. Keep added fields optional or defaulted so the + store creates succeed; the TanStack AI stores never read or write them. diff --git a/docs/persistence/sql-backends.md b/docs/persistence/sql-backends.md new file mode 100644 index 000000000..0e783907a --- /dev/null +++ b/docs/persistence/sql-backends.md @@ -0,0 +1,77 @@ +--- +title: SQL Backends +id: sql-backends +--- + +# SQL Backends + +TanStack AI ships two SQL-oriented state adapters with different ownership +models. + +| Adapter | Database support | Connection ownership | Schema workflow | +| --- | --- | --- | --- | +| `@tanstack/ai-persistence-drizzle` | SQLite-family only | Bring a migrated Drizzle DB, or use Node `/sqlite` | Bundled SQLite migration manifest and CLI | +| `@tanstack/ai-persistence-prisma` | Providers supported by your Prisma schema | Bring your generated `PrismaClient` | Copy models fragment, then use Prisma migrate | + +The Drizzle adapter does not accept a dialect selector. Its schema and stores +use SQLite APIs. For a non-SQLite Drizzle database, implement the public +`AIPersistence` store interfaces for that dialect. + +## Local SQLite + +```ts +import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite' + +export const persistence = sqlitePersistence({ + url: 'file:.tanstack-ai/state.sqlite', + migrate: true, +}) +``` + +## Existing SQLite or D1 Drizzle database + +```ts ignore +import { drizzle } from 'drizzle-orm/d1' +import { drizzlePersistence, schema } from '@tanstack/ai-persistence-drizzle' + +export function createPersistence(state: D1Database) { + const db = drizzle(state, { schema }) + return drizzlePersistence(db) +} +``` + +The package root is edge-safe; `/sqlite` is Node-only. + +## Prisma + +```ts ignore +import { PrismaClient } from '@prisma/client' +import { prismaPersistence } from '@tanstack/ai-persistence-prisma' + +const prisma = new PrismaClient() +export const persistence = prismaPersistence(prisma) +``` + +Copy the package's models fragment and create provider-native migrations before +constructing the adapter. See [Prisma](./prisma). + +## Store coverage + +Both adapters provide messages, runs, interrupts, and metadata. Neither +provides a `locks` store; add a distributed store when multiple processes can +mutate the same run. A distributed lock must implement the `LockStore` contract +from `@tanstack/ai-persistence`: + +```ts +import { composePersistence } from '@tanstack/ai-persistence' +import { persistence } from './persistence' +import { distributedLocks } from './locks' + +const coordinated = composePersistence(persistence, { + overrides: { locks: distributedLocks }, +}) +``` + +For Cloudflare-native state, [Cloudflare Persistence](./cloudflare) combines +D1 and Durable Object locks. For another SQL library, start with +[Custom Stores](./custom-stores). diff --git a/docs/resumable-streams/advanced.md b/docs/resumable-streams/advanced.md index 640e172b4..30fb1e965 100644 --- a/docs/resumable-streams/advanced.md +++ b/docs/resumable-streams/advanced.md @@ -247,4 +247,4 @@ response helper. The durability log replays chunks. It is not a queryable source of truth for thread messages or conversation history. It answers "what did this run stream?", not "what has this user said?". Keep authoritative state in your own storage. -See [Persistence](../chat/persistence) for the client-side options. +See [Client persistence](../persistence/client-persistence) for the client-side options. diff --git a/docs/resumable-streams/overview.md b/docs/resumable-streams/overview.md index 01f2b856f..3fe9067c4 100644 --- a/docs/resumable-streams/overview.md +++ b/docs/resumable-streams/overview.md @@ -23,6 +23,11 @@ response. The adapter records every chunk to an ordered log before delivery and tags each event with an opaque offset. On reconnect the client resends the last offset and the server replays from the log instead of re-running the model. +This is the delivery layer: it resumes a live stream. Saving the conversation so +it survives a reload or reaches another device is a separate layer. For how the +two fit together and when to pick each, see +[Durability and Persistence](../persistence/overview). + Three steps: pick an adapter, wrap your response with it, add a `GET` handler. ## 1. Pick an adapter diff --git a/examples/ts-react-chat/.gitignore b/examples/ts-react-chat/.gitignore index 029f7fba9..620400002 100644 --- a/examples/ts-react-chat/.gitignore +++ b/examples/ts-react-chat/.gitignore @@ -10,3 +10,4 @@ count.txt .output .vinxi todos.json +.data diff --git a/examples/ts-react-chat/README.md b/examples/ts-react-chat/README.md index 1a07c1533..2ccf972c2 100644 --- a/examples/ts-react-chat/README.md +++ b/examples/ts-react-chat/README.md @@ -369,6 +369,18 @@ Files prefixed with `demo` can be safely deleted. They are there to provide a st You can learn more about all of the offerings from TanStack in the [TanStack documentation](https://tanstack.com). +## Persistent chat (`/persistent-chat`) + +A chat that survives a full page reload on both ends. The client writes the +transcript to `localStorage` (via `localStoragePersistence`), so a reload +restores the conversation instantly. The server writes the same transcript, +run records, and interrupt state to SQLite with `withPersistence` +(`@tanstack/ai-persistence` + the Drizzle SQLite backend), so it survives a +server restart too — the DB lives at `.data/persistent-chat.db` (gitignored). + +Try it: send a message, wait for the reply, then reload the page. The +conversation is still there. Needs `OPENAI_API_KEY` in `.env`. + ## Sandboxes — GitHub issue triage (`/sandboxes`) Pick a harness adapter (Claude Code, Codex, OpenCode) and a sandbox diff --git a/examples/ts-react-chat/package.json b/examples/ts-react-chat/package.json index 94ee33bd0..986424f43 100644 --- a/examples/ts-react-chat/package.json +++ b/examples/ts-react-chat/package.json @@ -36,6 +36,8 @@ "@tanstack/ai-openai": "workspace:*", "@tanstack/ai-opencode": "workspace:*", "@tanstack/ai-openrouter": "workspace:*", + "@tanstack/ai-persistence": "workspace:*", + "@tanstack/ai-persistence-drizzle": "workspace:*", "@tanstack/ai-react": "workspace:*", "@tanstack/ai-react-ui": "workspace:*", "@tanstack/ai-sandbox": "workspace:*", diff --git a/examples/ts-react-chat/src/components/Header.tsx b/examples/ts-react-chat/src/components/Header.tsx index 62ebc7939..536495866 100644 --- a/examples/ts-react-chat/src/components/Header.tsx +++ b/examples/ts-react-chat/src/components/Header.tsx @@ -6,6 +6,7 @@ import { BadgeCheck, Braces, Code2, + Database, FileAudio, FileText, Guitar, @@ -313,6 +314,19 @@ export default function Header() { Resumable Streams + setIsOpen(false)} + className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2" + activeProps={{ + className: + 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2', + }} + > + + Persistent Chat + + setIsOpen(false)} diff --git a/examples/ts-react-chat/src/lib/persistent-chat-store.ts b/examples/ts-react-chat/src/lib/persistent-chat-store.ts new file mode 100644 index 000000000..bdc7c868e --- /dev/null +++ b/examples/ts-react-chat/src/lib/persistent-chat-store.ts @@ -0,0 +1,21 @@ +import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite' + +/** Stable thread id for the single-conversation demo. */ +export const PERSISTENT_CHAT_THREAD_ID = 'persistent-chat' + +let instance: ReturnType | undefined + +/** + * One SQLite-backed persistence store for the persistent-chat demo, shared by + * the API route (POST writes the transcript, GET replays / reconstructs it) and + * the history server function the page loader calls. Lazily opened so importing + * this module (e.g. from a server-fn module that a client route also imports) + * never opens the database in the browser bundle. `migrate: true` applies the + * bundled TanStack AI schema on first open. `.data/` is gitignored. + */ +export function persistentChatPersistence() { + return (instance ??= sqlitePersistence({ + url: './.data/persistent-chat.db', + migrate: true, + })) +} diff --git a/examples/ts-react-chat/src/lib/server-fns.ts b/examples/ts-react-chat/src/lib/server-fns.ts index 1c8109be4..bc7cd6ae3 100644 --- a/examples/ts-react-chat/src/lib/server-fns.ts +++ b/examples/ts-react-chat/src/lib/server-fns.ts @@ -8,9 +8,14 @@ import { generateTranscription, generateVideo, getVideoJobStatus, + modelMessagesToUIMessages, summarize, toServerSentEventsResponse, } from '@tanstack/ai' +import { + PERSISTENT_CHAT_THREAD_ID, + persistentChatPersistence, +} from './persistent-chat-store' import { openaiImage, openaiSummarize, @@ -414,3 +419,27 @@ export const chatFn = createServerFn({ method: 'POST' }) }), ), ) + +// ============================================================================= +// Persistent-chat history — server-authoritative hydration +// ============================================================================= + +/** + * Read the durable transcript for the persistent-chat demo and return it as UI + * messages, ready to seed `useChat({ initialMessages })`. The page runs + * `persistence: { store, messages: false }`, so it caches only the resume + * pointer and no transcript — history comes from the server on load. The page + * loader calls this instead of `fetch`ing the GET endpoint so it works during + * SSR (a relative fetch has no origin server-side); the GET endpoint's + * `reconstructChat` branch reads the same store over HTTP for other clients. + */ +export const loadPersistentChatHistoryFn = createServerFn().handler( + async (): Promise> => { + const persistence = persistentChatPersistence() + const stored = + (await persistence.stores.messages?.loadThread( + PERSISTENT_CHAT_THREAD_ID, + )) ?? [] + return modelMessagesToUIMessages(stored) + }, +) diff --git a/examples/ts-react-chat/src/routeTree.gen.ts b/examples/ts-react-chat/src/routeTree.gen.ts index 2b0dfdb40..775782871 100644 --- a/examples/ts-react-chat/src/routeTree.gen.ts +++ b/examples/ts-react-chat/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as SandboxesRouteImport } from './routes/sandboxes' import { Route as ResumableRouteImport } from './routes/resumable' import { Route as RealtimeRouteImport } from './routes/realtime' import { Route as QueueingRouteImport } from './routes/queueing' +import { Route as PersistentChatRouteImport } from './routes/persistent-chat' import { Route as McpDemoRouteImport } from './routes/mcp-demo' import { Route as McpAppsRouteImport } from './routes/mcp-apps' import { Route as Issue176ToolResultRouteImport } from './routes/issue-176-tool-result' @@ -41,6 +42,7 @@ import { Route as ApiStructuredOutputRouteImport } from './routes/api.structured import { Route as ApiStructuredChatRouteImport } from './routes/api.structured-chat' import { Route as ApiSandboxTriageRouteImport } from './routes/api.sandbox-triage' import { Route as ApiResumableRouteImport } from './routes/api.resumable' +import { Route as ApiPersistentChatRouteImport } from './routes/api.persistent-chat' import { Route as ApiMcpStatusRouteImport } from './routes/api.mcp-status' import { Route as ApiMcpPoolRouteImport } from './routes/api.mcp-pool' import { Route as ApiMcpManualRouteImport } from './routes/api.mcp-manual' @@ -95,6 +97,11 @@ const QueueingRoute = QueueingRouteImport.update({ path: '/queueing', getParentRoute: () => rootRouteImport, } as any) +const PersistentChatRoute = PersistentChatRouteImport.update({ + id: '/persistent-chat', + path: '/persistent-chat', + getParentRoute: () => rootRouteImport, +} as any) const McpDemoRoute = McpDemoRouteImport.update({ id: '/mcp-demo', path: '/mcp-demo', @@ -223,6 +230,11 @@ const ApiResumableRoute = ApiResumableRouteImport.update({ path: '/api/resumable', getParentRoute: () => rootRouteImport, } as any) +const ApiPersistentChatRoute = ApiPersistentChatRouteImport.update({ + id: '/api/persistent-chat', + path: '/api/persistent-chat', + getParentRoute: () => rootRouteImport, +} as any) const ApiMcpStatusRoute = ApiMcpStatusRouteImport.update({ id: '/api/mcp-status', path: '/api/mcp-status', @@ -324,6 +336,7 @@ export interface FileRoutesByFullPath { '/issue-176-tool-result': typeof Issue176ToolResultRoute '/mcp-apps': typeof McpAppsRoute '/mcp-demo': typeof McpDemoRoute + '/persistent-chat': typeof PersistentChatRoute '/queueing': typeof QueueingRoute '/realtime': typeof RealtimeRoute '/resumable': typeof ResumableRoute @@ -343,6 +356,7 @@ export interface FileRoutesByFullPath { '/api/mcp-manual': typeof ApiMcpManualRoute '/api/mcp-pool': typeof ApiMcpPoolRoute '/api/mcp-status': typeof ApiMcpStatusRoute + '/api/persistent-chat': typeof ApiPersistentChatRoute '/api/resumable': typeof ApiResumableRoute '/api/sandbox-triage': typeof ApiSandboxTriageRoute '/api/structured-chat': typeof ApiStructuredChatRoute @@ -376,6 +390,7 @@ export interface FileRoutesByTo { '/issue-176-tool-result': typeof Issue176ToolResultRoute '/mcp-apps': typeof McpAppsRoute '/mcp-demo': typeof McpDemoRoute + '/persistent-chat': typeof PersistentChatRoute '/queueing': typeof QueueingRoute '/realtime': typeof RealtimeRoute '/resumable': typeof ResumableRoute @@ -395,6 +410,7 @@ export interface FileRoutesByTo { '/api/mcp-manual': typeof ApiMcpManualRoute '/api/mcp-pool': typeof ApiMcpPoolRoute '/api/mcp-status': typeof ApiMcpStatusRoute + '/api/persistent-chat': typeof ApiPersistentChatRoute '/api/resumable': typeof ApiResumableRoute '/api/sandbox-triage': typeof ApiSandboxTriageRoute '/api/structured-chat': typeof ApiStructuredChatRoute @@ -429,6 +445,7 @@ export interface FileRoutesById { '/issue-176-tool-result': typeof Issue176ToolResultRoute '/mcp-apps': typeof McpAppsRoute '/mcp-demo': typeof McpDemoRoute + '/persistent-chat': typeof PersistentChatRoute '/queueing': typeof QueueingRoute '/realtime': typeof RealtimeRoute '/resumable': typeof ResumableRoute @@ -448,6 +465,7 @@ export interface FileRoutesById { '/api/mcp-manual': typeof ApiMcpManualRoute '/api/mcp-pool': typeof ApiMcpPoolRoute '/api/mcp-status': typeof ApiMcpStatusRoute + '/api/persistent-chat': typeof ApiPersistentChatRoute '/api/resumable': typeof ApiResumableRoute '/api/sandbox-triage': typeof ApiSandboxTriageRoute '/api/structured-chat': typeof ApiStructuredChatRoute @@ -483,6 +501,7 @@ export interface FileRouteTypes { | '/issue-176-tool-result' | '/mcp-apps' | '/mcp-demo' + | '/persistent-chat' | '/queueing' | '/realtime' | '/resumable' @@ -502,6 +521,7 @@ export interface FileRouteTypes { | '/api/mcp-manual' | '/api/mcp-pool' | '/api/mcp-status' + | '/api/persistent-chat' | '/api/resumable' | '/api/sandbox-triage' | '/api/structured-chat' @@ -535,6 +555,7 @@ export interface FileRouteTypes { | '/issue-176-tool-result' | '/mcp-apps' | '/mcp-demo' + | '/persistent-chat' | '/queueing' | '/realtime' | '/resumable' @@ -554,6 +575,7 @@ export interface FileRouteTypes { | '/api/mcp-manual' | '/api/mcp-pool' | '/api/mcp-status' + | '/api/persistent-chat' | '/api/resumable' | '/api/sandbox-triage' | '/api/structured-chat' @@ -587,6 +609,7 @@ export interface FileRouteTypes { | '/issue-176-tool-result' | '/mcp-apps' | '/mcp-demo' + | '/persistent-chat' | '/queueing' | '/realtime' | '/resumable' @@ -606,6 +629,7 @@ export interface FileRouteTypes { | '/api/mcp-manual' | '/api/mcp-pool' | '/api/mcp-status' + | '/api/persistent-chat' | '/api/resumable' | '/api/sandbox-triage' | '/api/structured-chat' @@ -640,6 +664,7 @@ export interface RootRouteChildren { Issue176ToolResultRoute: typeof Issue176ToolResultRoute McpAppsRoute: typeof McpAppsRoute McpDemoRoute: typeof McpDemoRoute + PersistentChatRoute: typeof PersistentChatRoute QueueingRoute: typeof QueueingRoute RealtimeRoute: typeof RealtimeRoute ResumableRoute: typeof ResumableRoute @@ -659,6 +684,7 @@ export interface RootRouteChildren { ApiMcpManualRoute: typeof ApiMcpManualRoute ApiMcpPoolRoute: typeof ApiMcpPoolRoute ApiMcpStatusRoute: typeof ApiMcpStatusRoute + ApiPersistentChatRoute: typeof ApiPersistentChatRoute ApiResumableRoute: typeof ApiResumableRoute ApiSandboxTriageRoute: typeof ApiSandboxTriageRoute ApiStructuredChatRoute: typeof ApiStructuredChatRoute @@ -734,6 +760,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof QueueingRouteImport parentRoute: typeof rootRouteImport } + '/persistent-chat': { + id: '/persistent-chat' + path: '/persistent-chat' + fullPath: '/persistent-chat' + preLoaderRoute: typeof PersistentChatRouteImport + parentRoute: typeof rootRouteImport + } '/mcp-demo': { id: '/mcp-demo' path: '/mcp-demo' @@ -909,6 +942,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiResumableRouteImport parentRoute: typeof rootRouteImport } + '/api/persistent-chat': { + id: '/api/persistent-chat' + path: '/api/persistent-chat' + fullPath: '/api/persistent-chat' + preLoaderRoute: typeof ApiPersistentChatRouteImport + parentRoute: typeof rootRouteImport + } '/api/mcp-status': { id: '/api/mcp-status' path: '/api/mcp-status' @@ -1048,6 +1088,7 @@ const rootRouteChildren: RootRouteChildren = { Issue176ToolResultRoute: Issue176ToolResultRoute, McpAppsRoute: McpAppsRoute, McpDemoRoute: McpDemoRoute, + PersistentChatRoute: PersistentChatRoute, QueueingRoute: QueueingRoute, RealtimeRoute: RealtimeRoute, ResumableRoute: ResumableRoute, @@ -1067,6 +1108,7 @@ const rootRouteChildren: RootRouteChildren = { ApiMcpManualRoute: ApiMcpManualRoute, ApiMcpPoolRoute: ApiMcpPoolRoute, ApiMcpStatusRoute: ApiMcpStatusRoute, + ApiPersistentChatRoute: ApiPersistentChatRoute, ApiResumableRoute: ApiResumableRoute, ApiSandboxTriageRoute: ApiSandboxTriageRoute, ApiStructuredChatRoute: ApiStructuredChatRoute, diff --git a/examples/ts-react-chat/src/routes/api.persistent-chat.ts b/examples/ts-react-chat/src/routes/api.persistent-chat.ts new file mode 100644 index 000000000..efad3c80e --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.persistent-chat.ts @@ -0,0 +1,205 @@ +import { createFileRoute } from '@tanstack/react-router' +import { z } from 'zod' +import { + EventType, + chat, + chatParamsFromRequestBody, + maxIterations, + memoryStream, + resumeServerSentEventsResponse, + toolDefinition, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { reconstructChat, withPersistence } from '@tanstack/ai-persistence' +import type { StreamChunk } from '@tanstack/ai' +import { persistentChatPersistence } from '../lib/persistent-chat-store' + +const persistence = persistentChatPersistence() + +// Two server-executed tools so the demo exercises the agent loop AND tool-call +// persistence: the assistant's tool calls and their results are written to the +// stored transcript, so a reload rehydrates them too — not just plain text. + +const WEATHER = { + sunny: { emoji: '☀️', tempC: 24 }, + cloudy: { emoji: '☁️', tempC: 17 }, + rainy: { emoji: '🌧️', tempC: 12 }, +} as const +const CONDITIONS = Object.keys(WEATHER) as Array + +const getWeather = toolDefinition({ + name: 'getWeather', + description: 'Get the current weather for a city.', + inputSchema: z.object({ city: z.string().describe('City name') }), + outputSchema: z.object({ + city: z.string(), + condition: z.string(), + emoji: z.string(), + tempC: z.number(), + }), +}).server(({ city }) => { + // Deterministic mock: pick a condition from the city name so a given city + // always reports the same weather (no external API needed for the demo). + const seed = [...city].reduce((sum, char) => sum + char.charCodeAt(0), 0) + const condition = CONDITIONS[seed % CONDITIONS.length]! + return { city, condition, ...WEATHER[condition] } +}) + +const rollDice = toolDefinition({ + name: 'rollDice', + description: 'Roll one or more dice and return the results.', + inputSchema: z.object({ + sides: z.number().int().min(2).max(100).default(6), + count: z.number().int().min(1).max(10).default(1), + }), + outputSchema: z.object({ + rolls: z.array(z.number()), + total: z.number(), + }), +}).server(({ sides, count }) => { + const rolls = Array.from( + { length: count }, + () => Math.floor(Math.random() * sides) + 1, + ) + return { rolls, total: rolls.reduce((sum, roll) => sum + roll, 0) } +}) + +const SYSTEM_PROMPT = + 'You are a concise, friendly assistant. When the user asks about the ' + + 'weather or to roll dice, use the getWeather / rollDice tools rather than ' + + 'guessing, then summarize the result in a sentence.' + +// Detached runs in flight, keyed by runId, so a duplicate POST (a client retry +// or React strict-mode double render) attaches to the existing run instead of +// starting a second model call. +const activeRuns = new Set() + +/** + * Start the model run **detached from the HTTP connection** and let it run to + * completion into the delivery log, regardless of whether the requesting client + * stays connected. This is the "don't abort on disconnect, because it's + * persisted" policy: because `withPersistence` writes the transcript on finish, + * it's safe to keep generating after a reload — the result is captured either + * way (the loader rehydrates it, and a rejoining client tails the same log). + * + * Contrast the plain resumable route (`/api/resumable`), which ties the run to + * the request's `abortController`: with no persistence, continuing after a + * disconnect would just burn tokens no one reads, so it aborts. + */ +function startDetachedRun( + runId: string, + threadId: string, + messages: Awaited>['messages'], +): void { + if (activeRuns.has(runId)) return + activeRuns.add(runId) + + // Producer-mode durability handle, keyed by runId via the X-Run-Id header. + const sink = memoryStream( + new Request('http://persistent-chat.internal/', { + headers: { 'X-Run-Id': runId }, + }), + ) + + const stream = chat({ + adapter: openaiText('gpt-5.5'), + // Snapshot streaming on: even the partial reply is persisted, so a reload + // mid-generation (or a crash) still shows the story-so-far, and the detached + // run below finishes and persists the rest. + middleware: [withPersistence(persistence, { snapshotStreaming: true })], + agentLoopStrategy: maxIterations(10), + systemPrompts: [SYSTEM_PROMPT], + tools: [getWeather, rollDice], + messages, + threadId, + runId, + // No client abortController: the run owns its own lifetime. + }) + + void (async () => { + try { + for await (const chunk of stream) { + await sink.append([chunk]) + } + } catch (error) { + // The run threw before emitting a terminal chunk (withPersistence.onError + // already recorded the failure). Append a RUN_ERROR so log readers unblock + // instead of waiting on a stream that will never finish. + await sink.append([ + { + type: EventType.RUN_ERROR, + message: error instanceof Error ? error.message : String(error), + timestamp: Date.now(), + } as StreamChunk, + ]) + } finally { + await sink.close() + activeRuns.delete(runId) + } + })() +} + +/** + * Persistent-chat demo endpoint. + * + * Three kinds of durability stack here: + * + * 1. STATE — `withPersistence` writes the thread transcript (including the + * pending user turn at start and throttled streaming snapshots), run records, + * and interrupt state to SQLite. Survives a server restart. + * + * 2. DELIVERY — the `memoryStream` log records each chunk so a reconnecting or + * rejoining client replays without re-running the model. Swap it for + * `durableStream(request, { server })` from `@tanstack/ai-durable-stream` in + * production (memoryStream is process-local). + * + * 3. RUN LIFETIME — the run is detached from the HTTP request (see + * `startDetachedRun`), so a mid-stream reload does not abort it. It finishes, + * persists, and the reload rehydrates the full conversation. + * + * The store is shared with the page's history server function (see + * `../lib/persistent-chat-store`), which the loader calls to hydrate the + * transcript on load — the client runs server-authoritative + * (`persistence: { store, messages: false }`) and keeps no messages of its own. + */ + +export const Route = createFileRoute('/api/persistent-chat')({ + server: { + handlers: { + POST: async ({ request }) => { + const params = await chatParamsFromRequestBody(await request.json()) + + // Kick off (or attach to) the detached run, then stream it to THIS + // client by tailing the delivery log from the start. Cancelling this + // response (a reload) cancels only the reader — never the producer. + startDetachedRun(params.runId, params.threadId, params.messages) + + const reader = memoryStream( + new Request( + `http://persistent-chat.internal/?offset=-1&runId=${encodeURIComponent(params.runId)}`, + ), + ) + return resumeServerSentEventsResponse({ adapter: reader }) + }, + + // GET serves two independent jobs off one route: + // + // 1. Delivery replay — re-attach to an in-flight run off the ephemeral, + // per-run durability log. The run id rides the `X-Run-Id` header (or + // `?runId`) and the resume offset the `Last-Event-ID` header (or + // `?offset`), so ask the adapter via `resumeFrom()` rather than + // sniffing query params. + // 2. History hydration — read the DURABLE thread transcript from the + // persistence store. This is what a server-authoritative client + // (persistence `{ messages: false }`) fetches on reload, since the + // delivery log only holds one run, never prior turns. + GET: ({ request }) => { + const durability = memoryStream(request) + if (durability.resumeFrom() !== null) { + return resumeServerSentEventsResponse({ adapter: durability }) + } + return reconstructChat(persistence, request) + }, + }, + }, +}) diff --git a/examples/ts-react-chat/src/routes/persistent-chat.css b/examples/ts-react-chat/src/routes/persistent-chat.css new file mode 100644 index 000000000..bc9ba9f84 --- /dev/null +++ b/examples/ts-react-chat/src/routes/persistent-chat.css @@ -0,0 +1,233 @@ +.pc-page { + --pc-bg: #0b0c10; + --pc-panel: #14161d; + --pc-panel-2: #1b1e27; + --pc-border: #262a36; + --pc-text: #e7e9ee; + --pc-muted: #9aa3b2; + --pc-accent: #6366f1; + --pc-user: #6366f1; + --pc-assistant: #1b1e27; + --pc-tool: #12261f; + --pc-tool-border: #1f5c45; + + max-width: 780px; + margin: 20px auto; + padding: 22px 22px 0; + height: calc(100vh - 40px); + display: flex; + flex-direction: column; + color: var(--pc-text); + background: var(--pc-bg); + border: 1px solid var(--pc-border); + border-radius: 16px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.28); + overflow: hidden; + font-family: + ui-sans-serif, + system-ui, + -apple-system, + 'Segoe UI', + Roboto, + sans-serif; +} + +.pc-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.pc-header h1 { + margin: 0; + font-size: 20px; + font-weight: 650; + letter-spacing: -0.01em; +} + +.pc-status { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--pc-muted); +} + +.pc-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #f59e0b; +} +.pc-dot.connected { + background: #22c55e; +} +.pc-dot.error { + background: #ef4444; +} + +.pc-blurb { + margin: 10px 0 16px; + font-size: 13px; + line-height: 1.55; + color: var(--pc-muted); +} +.pc-blurb code { + background: var(--pc-panel-2); + border: 1px solid var(--pc-border); + border-radius: 5px; + padding: 1px 5px; + font-size: 12px; + color: var(--pc-text); +} + +.pc-thread { + flex: 1; + display: flex; + flex-direction: column; + gap: 14px; + overflow-y: auto; + padding: 4px 2px 20px; +} + +.pc-empty { + margin: auto; + color: var(--pc-muted); + font-size: 14px; + text-align: center; +} + +.pc-row { + display: flex; + flex-direction: column; + gap: 6px; + max-width: 88%; +} +.pc-row.user { + align-self: flex-end; + align-items: flex-end; +} +.pc-row.assistant { + align-self: flex-start; + align-items: flex-start; +} + +.pc-role { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--pc-muted); +} + +.pc-bubble { + border-radius: 14px; + padding: 10px 14px; + font-size: 14.5px; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; +} +.pc-row.user .pc-bubble { + background: var(--pc-user); + color: #fff; + border-bottom-right-radius: 4px; +} +.pc-row.assistant .pc-bubble { + background: var(--pc-assistant); + border: 1px solid var(--pc-border); + border-bottom-left-radius: 4px; +} + +.pc-tool { + background: var(--pc-tool); + border: 1px solid var(--pc-tool-border); + border-radius: 12px; + padding: 8px 12px; + font-size: 13px; + min-width: 220px; +} +.pc-tool-head { + display: flex; + align-items: center; + gap: 7px; + font-weight: 600; + color: #7fe3b8; +} +.pc-tool-io { + margin: 6px 0 0; + padding: 6px 8px; + background: rgba(0, 0, 0, 0.28); + border-radius: 7px; + font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; + font-size: 12px; + color: var(--pc-text); + overflow-x: auto; + white-space: pre; +} +.pc-tool-pending { + color: var(--pc-muted); + font-style: italic; +} + +.pc-composer { + position: sticky; + bottom: 0; + display: flex; + gap: 10px; + padding: 14px 0 22px; + background: linear-gradient(to top, var(--pc-bg) 72%, transparent); +} + +.pc-input { + flex: 1; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid var(--pc-border); + background: var(--pc-panel); + color: var(--pc-text); + font-size: 14.5px; + outline: none; +} +.pc-input:focus { + border-color: var(--pc-accent); +} +.pc-input::placeholder { + color: var(--pc-muted); +} + +.pc-send { + padding: 0 18px; + border-radius: 12px; + border: none; + background: var(--pc-accent); + color: #fff; + font-size: 14px; + font-weight: 600; + cursor: pointer; +} +.pc-send:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.pc-suggestions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 4px; +} +.pc-chip { + padding: 6px 11px; + border-radius: 999px; + border: 1px solid var(--pc-border); + background: var(--pc-panel); + color: var(--pc-muted); + font-size: 12.5px; + cursor: pointer; +} +.pc-chip:hover { + color: var(--pc-text); + border-color: var(--pc-accent); +} diff --git a/examples/ts-react-chat/src/routes/persistent-chat.tsx b/examples/ts-react-chat/src/routes/persistent-chat.tsx new file mode 100644 index 000000000..36081bfc8 --- /dev/null +++ b/examples/ts-react-chat/src/routes/persistent-chat.tsx @@ -0,0 +1,170 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useEffect, useRef, useState } from 'react' +import { + fetchServerSentEvents, + localStoragePersistence, +} from '@tanstack/ai-client' +import { useChat } from '@tanstack/ai-react' +import { loadPersistentChatHistoryFn } from '../lib/server-fns' +import './persistent-chat.css' + +export const Route = createFileRoute('/persistent-chat')({ + // Server-authoritative history: the client caches no transcript, so hydrate + // it from the server on load. Works during SSR (the server fn reads the store + // directly — no relative fetch that would fail without an origin). + loader: () => loadPersistentChatHistoryFn(), + component: PersistentChatPage, +}) + +const connection = fetchServerSentEvents('/api/persistent-chat') + +const THREAD_ID = 'persistent-chat' + +// Recommended setup: server-authoritative persistence. The client caches ONLY +// the resume pointer (which run to rejoin, which interrupts are pending) in +// localStorage — never the transcript. So a reload still rejoins an in-flight +// run and restores interrupts, but large histories stay off the client and the +// server (SQLite) owns the conversation. `localStoragePersistence()` defaults +// to a JSON codec and the ChatPersistedState shape, so no type arg or codec. +const persistence = { store: localStoragePersistence(), messages: false } + +const SUGGESTIONS = [ + "What's the weather in Tokyo?", + 'Roll two 20-sided dice.', + 'Tell me a two-sentence story about a lighthouse.', +] + +function formatValue(value: unknown): string { + if (value === undefined) return '' + if (typeof value === 'string') return value + return JSON.stringify(value, null, 2) +} + +function PersistentChatPage() { + // The loader hydrated the transcript from the server (server owns history). + const initialMessages = Route.useLoaderData() + + // A stable threadId so a reload continues the SAME conversation: the server + // keys its stored thread on it, and the client keys its resume pointer on it. + const { messages, sendMessage, isLoading, connectionStatus } = useChat({ + threadId: THREAD_ID, + connection, + persistence, + initialMessages, + }) + const [input, setInput] = useState('') + const threadRef = useRef(null) + + // Keep the latest message in view as the conversation grows / streams. + useEffect(() => { + threadRef.current?.scrollTo({ top: threadRef.current.scrollHeight }) + }, [messages]) + + const send = (text: string) => { + const trimmed = text.trim() + if (!trimmed || isLoading) return + setInput('') + void sendMessage(trimmed) + } + + return ( +
+
+

Persistent chat

+ + + {connectionStatus} + +
+ +

+ Server-authoritative persistence: the server owns the conversation + (transcript, runs, interrupts, and tool calls) in SQLite via{' '} + withPersistence, and the page loader hydrates it on load. + The client caches only the resume pointer (messages: false) + — no transcript in the browser. Ask for the weather or a dice roll to + exercise server tools, then reload: everything comes back from the + server. +

+ +
+ {messages.length === 0 ? ( +

+ No messages yet — try a suggestion below, then reload the page. +

+ ) : ( + messages.map((message) => ( +
+ {message.role} + {message.parts.map((part, index) => { + const key = `${message.id}-${index}` + if (part.type === 'text' && part.content) { + return ( +
+ {part.content} +
+ ) + } + if (part.type === 'tool-call') { + const args = part.input ?? part.arguments + return ( +
+
🔧 {part.name}
+ {formatValue(args) ? ( +
{formatValue(args)}
+ ) : null} + {part.output !== undefined ? ( +
+                          {formatValue(part.output)}
+                        
+ ) : ( +
running…
+ )} +
+ ) + } + return null + })} +
+ )) + )} +
+ +
+
+ {messages.length === 0 ? ( +
+ {SUGGESTIONS.map((suggestion) => ( + + ))} +
+ ) : null} +
{ + e.preventDefault() + send(input) + }} + style={{ display: 'flex', gap: 10 }} + > + setInput(e.target.value)} + placeholder="Ask about the weather, roll some dice…" + /> + +
+
+
+
+ ) +} diff --git a/packages/ai-angular/src/index.ts b/packages/ai-angular/src/index.ts index 4f9565c58..1752b05d5 100644 --- a/packages/ai-angular/src/index.ts +++ b/packages/ai-angular/src/index.ts @@ -77,6 +77,17 @@ export type { // Re-export from @tanstack/ai-client for convenience export { fetchServerSentEvents, + localStoragePersistence, + sessionStoragePersistence, + indexedDBPersistence, + StorageUnavailableError, + type ChatClientPersistence, + type ChatPersistedState, + type ChatPersistenceConfig, + type ChatPersistenceOption, + type ChatStorageAdapter, + type WebStoragePersistenceOptions, + type IndexedDBPersistenceOptions, fetchHttpStream, xhrServerSentEvents, xhrHttpStream, diff --git a/packages/ai-client/src/chat-client.ts b/packages/ai-client/src/chat-client.ts index 920f6cd5d..d85f6de40 100644 --- a/packages/ai-client/src/chat-client.ts +++ b/packages/ai-client/src/chat-client.ts @@ -248,9 +248,11 @@ export class ChatClient< private connection: SubscribeConnectionAdapter private readonly uniqueId: string private readonly threadId: string - // Message persistence (optional). Clear-during-stream suppression is always - // on via ClearedStreamTracker so `clear()` works without a storage adapter. - // Durable resume-snapshot storage is not wired here (feat/persistence). + // Durable chat persistence (optional): messages + resume snapshot as one + // combined record, so a full page reload restores the transcript, rehydrates + // pending interrupts, and rejoins an in-flight run. Clear-during-stream + // suppression is always on via ClearedStreamTracker so `clear()` works + // without a storage adapter. private readonly persistor?: ChatPersistor private readonly clearedStreamTracker = new ClearedStreamTracker() private currentRunId: string | null = null @@ -258,6 +260,10 @@ export class ChatClient< // run, so approvals/client-tool results can be sent back. Cleared when the // run terminates. This is STATE (interrupt) resume, not delivery/cursor. private lastResume: ChatResumeState | null = null + // The in-flight run id already handed to `resumeInFlightRun`, so a persisted + // run is rejoined at most once even when both the sync read and the async + // hydrate surface the same resume pointer. + private rejoinedRunId: string | null = null private readonly interruptManager: InterruptManager private activeInterruptSubmission: InterruptManagerSubmission | undefined private interruptSubmissionFailure: @@ -370,11 +376,33 @@ export class ChatClient< constructor(options: ChatClientOptions) { this.uniqueId = options.id || this.generateUniqueId('chat') this.threadId = options.threadId || this.generateUniqueId('thread') + // Whether the persistor caches the transcript. In `{ messages: false }` + // mode it does not, so the persisted record's empty transcript must never + // override host-provided `initialMessages` (history the app loaded from the + // server); see the initialMessages resolution below. + let cachesMessages = true if (options.persistence) { + // The `persistence` option is either a bare adapter (store everything) or + // `{ store, messages }`. `messages: false` caches only the resume pointer, + // leaving the transcript off the client (server-authoritative history). + const store = + 'store' in options.persistence + ? options.persistence.store + : options.persistence + cachesMessages = + 'store' in options.persistence + ? options.persistence.messages !== false + : true + // Persistence keys on `threadId` (the conversation identity) so a reload + // with the same `threadId` finds the same record. `id` overrides it only + // when set, for apps that key storage separately from the wire thread. + const persistenceKey = options.id ?? this.threadId this.persistor = new ChatPersistor( - options.persistence, - this.uniqueId, + store, + persistenceKey, (messages) => this.processor.setMessages(messages), + (snapshot) => this.applyPersistedResume(snapshot), + cachesMessages, ) } // Both `body` (deprecated) and `forwardedProps` populate the AG-UI @@ -439,10 +467,33 @@ export class ChatClient< // Create StreamProcessor with event handlers. // Use conditional spreads so we don't pass `undefined` into // `StreamProcessorOptions` fields under `exactOptionalPropertyTypes`. - const persistedMessages = this.persistor?.readInitial() - const initialMessages = Array.isArray(persistedMessages) - ? persistedMessages - : options.initialMessages + const persistedState = this.persistor?.readInitial() + const syncPersistedState = + persistedState instanceof Promise ? undefined : persistedState + // Adopt the persisted transcript only when we actually cache it. In + // `messages: false` mode the record's empty `messages` is "not stored here", + // not "the conversation is empty", so fall back to host `initialMessages` + // (e.g. history the app fetched from the server) and take only `resume`. + const initialMessages = + cachesMessages && syncPersistedState + ? syncPersistedState.messages + : options.initialMessages + // A durable snapshot read synchronously from storage wins over the + // in-memory `initialResumeSnapshot` fallback applied above. A snapshot with + // pending interrupts rehydrates the interrupt UI; a bare in-flight run is + // rejoined after the processor is ready (see `rejoinRunId` below). + let rejoinRunId: string | null = null + if (syncPersistedState?.resume) { + const snapshot = syncPersistedState.resume + const hasPendingInterrupts = + Array.isArray(snapshot.pendingInterrupts) && + snapshot.pendingInterrupts.length > 0 + if (hasPendingInterrupts) { + this.applyResumeSnapshot(snapshot) + } else if (snapshot.resumeState.runId) { + rejoinRunId = snapshot.resumeState.runId + } + } this.processor = new StreamProcessor({ ...(options.streamProcessor?.chunkStrategy @@ -649,7 +700,15 @@ export class ChatClient< }, }) - this.persistor?.hydrateAsync(persistedMessages) + this.persistor?.hydrateAsync(persistedState) + + // Full page reload with an in-flight run persisted (synchronous store): + // re-attach to it off the server's delivery-durability log so the stream + // finishes here. Async stores rejoin from `applyPersistedResume` once the + // hydrate resolves. Best-effort and non-blocking. + if (rejoinRunId) { + this.maybeRejoinInFlight(rejoinRunId) + } } private applyResumeSnapshot(snapshot: ChatResumeSnapshot): void { @@ -675,6 +734,38 @@ export class ChatClient< }) } + /** + * Apply a resume snapshot read from durable storage. Restores interrupt state, + * and for a bare in-flight run (no pending interrupts) also rejoins it. This is + * the async-store counterpart to the synchronous rejoin in the constructor: + * `applyResumeSnapshot` alone only handles interrupts, so an async store + * (`indexedDBPersistence`) would otherwise never rejoin a mid-stream run. + */ + private applyPersistedResume(snapshot: ChatResumeSnapshot): void { + this.applyResumeSnapshot(snapshot) + const hasInterrupts = + Array.isArray(snapshot.pendingInterrupts) && + snapshot.pendingInterrupts.length > 0 + const runId = snapshot.resumeState?.runId + if (!hasInterrupts && runId) { + this.maybeRejoinInFlight(runId) + } + } + + /** + * Rejoin a persisted in-flight run, guarded so it fires at most once and never + * while another run is already active. Skipped when the connection is not + * resumable (`joinRun` absent), so a non-durable transport is a no-op. + */ + private maybeRejoinInFlight(runId: string): void { + if (!this.connection.joinRun) return + if (this.rejoinedRunId === runId) return + // A fresh send (or an already-running rejoin) owns the client; don't stomp it. + if (this.isLoading || this.abortController) return + this.rejoinedRunId = runId + this.resumeInFlightRun(runId) + } + mountDevtools(): void { if (this.devtoolsMounted) { return @@ -724,6 +815,16 @@ export class ChatClient< this.activeRunIds.add(chunkRunId) this.clearedStreamTracker.onRunStarted(chunkRunId) this.setSessionGenerating(true) + // Persist a live-run resume snapshot so a full page reload can rejoin this + // in-flight run via joinRun. Only meaningful when the connection is + // resumable; skipped otherwise. Interrupt/terminal handling overwrites or + // clears it in observeInterruptState. + if (this.persistor && this.connection.joinRun && !this.lastResume) { + this.persistResumeSnapshot({ + threadId: this.activeResumeThreadId ?? this.threadId, + runId: chunkRunId, + }) + } return } @@ -821,6 +922,9 @@ export class ChatClient< isActiveInterruptSubmissionTerminal ) { this.lastResume = null + // Run settled without an interrupt: drop the durable resume snapshot so a + // later reload does not try to rejoin a finished run. + this.persistor?.persistResumeSnapshot(null) this.interruptManager.reset() return } @@ -1017,6 +1121,10 @@ export class ChatClient< private notifyResumeStateChange(): void { const resumeState = this.getResumeState() + // Persist (or clear) the durable resume snapshot so a full page reload can + // rehydrate pending interrupts and rejoin the run. Folded into the same + // persistence adapter that stores messages (one record per chat). + this.persistResumeSnapshot(resumeState) this.callbacksRef.current.onResumeStateChange( resumeState, this.interruptManager.getInterrupts(), @@ -1026,6 +1134,26 @@ export class ChatClient< ) } + /** + * Build the durable resume snapshot from the current resume state + pending + * interrupt descriptors and hand it to the persistor (null clears it). + */ + private persistResumeSnapshot(resumeState: ChatResumeState | null): void { + if (!this.persistor) return + if (!resumeState) { + this.persistor.persistResumeSnapshot(null) + return + } + const descriptors = this.interruptManager.getDescriptors() + this.persistor.persistResumeSnapshot({ + schemaVersion: 2, + resumeState, + ...(descriptors.length > 0 + ? { pendingInterrupts: [...descriptors] } + : {}), + }) + } + private resetSessionGenerating(options?: { preserveClearedStreamTracking?: boolean }): void { @@ -1186,7 +1314,72 @@ export class ChatClient< } } - private async processIncomingChunk(chunk: StreamChunk): Promise { + /** + * Re-attach to an in-flight run after a full page reload, replaying its stream + * from the server's delivery-durability log via `joinRun` (which returns the + * whole run so far, then tails live to completion). + * + * The log is the single source of truth for the run, so we rebuild the + * in-flight assistant bubble from it rather than trying to reconcile the + * server-hydrated partial with the replay: on the FIRST delivered chunk we + * drop the hydrated in-flight assistant, and the replay reconstructs one clean + * bubble. This is eviction-safe — if `joinRun` never yields (finished+evicted, + * or unknown run), we never drop, so the hydrated transcript stays intact. + * + * Replay chunks are processed WITHOUT the per-chunk yield the live path uses, + * so the buffered prefix snaps in and only the genuinely-live tail streams at + * network speed — a reload looks like the run continued, not like it re-typed. + */ + private resumeInFlightRun(runId: string): void { + const joinRun = this.connection.joinRun + if (!joinRun) return + const controller = new AbortController() + this.abortController = controller + this.currentRunId = runId + this.setIsLoading(true) + this.setStatus('streaming') + void (async () => { + let rebuilt = false + try { + for await (const chunk of joinRun(runId, controller.signal)) { + if (controller.signal.aborted) break + if (!rebuilt) { + rebuilt = true + this.dropTrailingInFlightAssistant() + } + await this.processIncomingChunk(chunk, { defer: false }) + } + } catch { + // Best-effort re-attach: the durable log may be gone or the run + // unknown. Keep the restored transcript rather than surfacing an error. + } finally { + if (this.abortController === controller) { + this.abortController = null + this.setIsLoading(false) + if (this.status === 'streaming') this.setStatus('ready') + } + } + })() + } + + /** + * Drop a hydrated, still-in-flight assistant turn so a resume replay can + * rebuild it cleanly. Only touches a trailing assistant message (the shape a + * reload-mid-stream leaves); a thread whose last turn is a user message (run + * never produced, or already settled) is left untouched. + */ + private dropTrailingInFlightAssistant(): void { + const messages = this.processor.getMessages() + const last = messages[messages.length - 1] + if (last && last.role === 'assistant') { + this.processor.setMessages(messages.slice(0, -1)) + } + } + + private async processIncomingChunk( + chunk: StreamChunk, + options?: { defer?: boolean }, + ): Promise { if ( chunk.type === 'RUN_ERROR' && this.isActiveInterruptSubmissionFailure(chunk) @@ -1216,7 +1409,13 @@ export class ChatClient< this.processor.processChunk(chunk) this.updateRunLifecycle(chunk) this.observeInterruptState(chunk) - await new Promise((resolve) => setTimeout(resolve, 0)) + // The live path yields a macrotask between chunks so React can paint each + // delta progressively. A resume replay passes `defer: false` to skip it, so + // the buffered backlog applies in one batch (instant catch-up) instead of + // re-typing the whole reply. + if (options?.defer !== false) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } this.resolveJoinedRun(chunk) } diff --git a/packages/ai-client/src/client-persistor.ts b/packages/ai-client/src/client-persistor.ts index 517aaf0a7..25c9c23ac 100644 --- a/packages/ai-client/src/client-persistor.ts +++ b/packages/ai-client/src/client-persistor.ts @@ -1,6 +1,20 @@ import { getChunkRunId } from './connection-adapters' import type { StreamChunk } from '@tanstack/ai/client' -import type { ChatClientPersistence, UIMessage } from './types' +import type { + ChatClientPersistence, + ChatPersistedState, + ChatResumeSnapshot, + UIMessage, +} from './types' + +/** Normalize a raw `getItem` result (legacy bare array or combined record). */ +function normalizePersistedState( + raw: ChatPersistedState | Array | null | undefined, +): ChatPersistedState | undefined { + if (Array.isArray(raw)) return { messages: raw } + if (raw && Array.isArray(raw.messages)) return raw + return undefined +} // `StreamChunk` is a discriminated union; `toolCallId` / `messageId` / // `parentMessageId` exist on only some members. Narrow with `in` (matching @@ -51,6 +65,10 @@ export class ChatPersistor { // Bumped on every message change; lets an in-flight async hydration detect // that the message list moved on and avoid clobbering it. private messagesGeneration = 0 + // Latest messages + resume snapshot, written together as one combined record + // so a full page reload restores both from a single adapter key. + private lastMessages: Array = [] + private lastResume: ChatResumeSnapshot | null = null // --- clear-during-stream suppression state --- private readonly clearedMessageIds = new Set() @@ -63,51 +81,96 @@ export class ChatPersistor { private readonly adapter: ChatClientPersistence, private readonly id: string, private readonly applyMessages: (messages: Array) => void, + private readonly applyResume?: (snapshot: ChatResumeSnapshot) => void, + // When false, the transcript is never cached client-side; only the tiny + // resume pointer is persisted (server-authoritative history). Defaults true. + private readonly storeMessages: boolean = true, ) {} + /** + * Persist the current state as one combined record. When `storeMessages` is + * false the transcript is omitted (empty), so only the resume pointer is + * written and large histories never hit client storage. + */ + private writeState(): void { + const messages = this.storeMessages ? [...this.lastMessages] : [] + // Nothing to persist (no transcript, no resume pointer): don't write an + // empty record. This keeps a cleared conversation from leaving an empty + // `{ messages: [] }` behind, matching the "removed, not emptied" contract. + if (messages.length === 0 && !this.lastResume) { + return + } + const generation = this.generation + const state: ChatPersistedState = { + messages, + ...(this.lastResume ? { resume: this.lastResume } : {}), + } + this.runOperation(() => { + if (generation !== this.generation) { + return + } + return this.adapter.setItem(this.id, state) + }) + } + // --------------------------------------------------------------------------- // Storage orchestration // --------------------------------------------------------------------------- /** - * Synchronously read the persisted messages for constructor-time hydration. - * Returns the raw `getItem` result (which may be a promise for async stores). + * Synchronously read the persisted state for constructor-time hydration. + * Returns the normalized combined record, or a promise of it for async stores. */ readInitial(): - | Array - | null + | ChatPersistedState | undefined - | Promise | null | undefined> { + | Promise { try { - return this.adapter.getItem(this.id) + const raw = this.adapter.getItem(this.id) + if (raw instanceof Promise) { + return raw.then(normalizePersistedState).catch(() => undefined) + } + const state = normalizePersistedState(raw) + if (state) { + this.lastMessages = state.messages + this.lastResume = state.resume ?? null + } + return state } catch { return undefined } } /** - * Apply messages from an async `getItem` once it resolves, unless the message + * Apply state from an async `getItem` once it resolves, unless the message * list has already changed since hydration began. */ hydrateAsync( - persistedMessages: - | Array - | null + persistedState: + | ChatPersistedState | undefined - | Promise | null | undefined>, + | Promise, ): void { - if (!(persistedMessages instanceof Promise)) { + if (!(persistedState instanceof Promise)) { return } const hydrationGeneration = this.messagesGeneration - persistedMessages - .then((messages) => { - if ( - Array.isArray(messages) && - this.messagesGeneration === hydrationGeneration - ) { - this.applyMessages(messages) + persistedState + .then((state) => { + if (!state || this.messagesGeneration !== hydrationGeneration) { + return + } + this.lastResume = state.resume ?? null + // Only apply the persisted transcript when we cache it. In + // `messages: false` mode the record's empty `messages` must not wipe + // host-provided initialMessages; still apply the resume snapshot. + if (this.storeMessages) { + this.lastMessages = state.messages + this.applyMessages(state.messages) + } + if (state.resume && this.applyResume) { + this.applyResume(state.resume) } }) .catch(() => { @@ -116,28 +179,37 @@ export class ChatPersistor { } /** - * Record a message-list change and queue a `setItem` write for it. Skips a + * Record a message-list change and queue a combined write for it. Skips a * single write after {@link beginClear} so the clear's empty snapshot isn't * persisted between `clearMessages()` and {@link remove}. */ notifyMessagesChanged(messages: Array): void { this.messagesGeneration++ + this.lastMessages = [...messages] if (this.skipNextPersist) { this.skipNextPersist = false return } - const generation = this.generation - const messagesSnapshot = [...messages] - this.runOperation(() => { - if (generation !== this.generation) { - return - } - return this.adapter.setItem(this.id, messagesSnapshot) - }) + this.writeState() + } + + /** + * Record the current resume snapshot (which run to rejoin / which interrupts + * are pending) and persist it alongside the messages. Pass `null` to clear it + * once the run reaches a non-interrupt terminal. + */ + persistResumeSnapshot(snapshot: ChatResumeSnapshot | null): void { + this.lastResume = snapshot + if (this.skipNextPersist) { + return + } + this.writeState() } /** Remove the persisted conversation. Invalidates any queued writes. */ remove(): void { + this.lastMessages = [] + this.lastResume = null const generation = ++this.generation this.runOperation(() => { if (generation !== this.generation) { diff --git a/packages/ai-client/src/connection-adapters.ts b/packages/ai-client/src/connection-adapters.ts index 57997518c..9ccf01e96 100644 --- a/packages/ai-client/src/connection-adapters.ts +++ b/packages/ai-client/src/connection-adapters.ts @@ -693,6 +693,16 @@ export interface SubscribeConnectionAdapter { abortSignal?: AbortSignal, runContext?: RunAgentInputContext, ) => Promise + /** + * Re-attach to an existing run by id, replaying its stream from the start off + * the server's delivery-durability sink. Present only when the underlying + * connection is resumable (a `ResumableConnectConnectionAdapter`). Used to + * rejoin an in-flight run after a full page reload. + */ + joinRun?: ( + runId: string, + abortSignal?: AbortSignal, + ) => AsyncIterable } /** @@ -727,9 +737,13 @@ export function normalizeConnectionAdapter( } if (hasSubscribe && hasSend) { + const joinRun = (connection as SubscribeConnectionAdapter).joinRun?.bind( + connection, + ) return { subscribe: connection.subscribe.bind(connection), send: connection.send.bind(connection), + ...(joinRun ? { joinRun } : {}), } } @@ -858,6 +872,17 @@ export function normalizeConnectionAdapter( throw err } }, + // Expose joinRun only when the underlying connection is resumable. Returns + // the replay iterable directly; the client consumes it like a subscription. + ...('joinRun' in connection + ? { + joinRun: (runId: string, abortSignal?: AbortSignal) => + (connection as ResumableConnectConnectionAdapter).joinRun( + runId, + abortSignal, + ), + } + : {}), } } diff --git a/packages/ai-client/src/index.ts b/packages/ai-client/src/index.ts index b03ed1480..cca4e4be6 100644 --- a/packages/ai-client/src/index.ts +++ b/packages/ai-client/src/index.ts @@ -28,6 +28,10 @@ export type { StructuredOutputPart, // Client configuration types ChatClientPersistence, + ChatPersistedState, + ChatPersistenceConfig, + ChatPersistenceOption, + ChatStorageAdapter, ChatClientOptions, ChatPendingInterrupt, BoundInterruptBase, @@ -84,6 +88,17 @@ export type { export { GENERATION_EVENTS } from './generation-types' export { UnsupportedResponseStreamError } from './response-stream' export { clientTools, createChatClientOptions } from './types' +// Web storage adapters for durable chat persistence (messages + resume snapshot) +export { + localStoragePersistence, + sessionStoragePersistence, + indexedDBPersistence, + StorageUnavailableError, +} from './storage-adapters' +export type { + WebStoragePersistenceOptions, + IndexedDBPersistenceOptions, +} from './storage-adapters' export { createAIDevtoolsGenerationPreview, type AIDevtoolsClientMetadata, diff --git a/packages/ai-client/src/storage-adapters.ts b/packages/ai-client/src/storage-adapters.ts new file mode 100644 index 000000000..23130495e --- /dev/null +++ b/packages/ai-client/src/storage-adapters.ts @@ -0,0 +1,245 @@ +import type { ChatPersistedState, ChatStorageAdapter } from './types' + +export interface WebStoragePersistenceOptions { + keyPrefix?: string + /** + * Defaults to `JSON.stringify`. Override only for values JSON can't + * round-trip losslessly (a `Map`, a `bigint`, a `Date` you need back as a + * `Date` rather than an ISO string). + */ + serialize?: (value: TValue) => string + /** Defaults to `JSON.parse`. */ + deserialize?: (value: string) => TValue +} + +export interface IndexedDBPersistenceOptions { + databaseName?: string + objectStoreName?: string + keyPrefix?: string +} + +type StorageName = 'localStorage' | 'sessionStorage' | 'indexedDB' + +/** + * Thrown by a storage adapter when its backing store is absent — most commonly + * during server-side rendering, where `localStorage` / `sessionStorage` / + * `indexedDB` do not exist on `globalThis`. The adapters check availability + * lazily, **per operation**, so constructing an adapter never throws; the error + * surfaces from `getItem` / `setItem` / `removeItem` (rejected promise for + * IndexedDB). The chat persistence layer treats it as best-effort and routes it + * to `onError` / `console.warn` rather than breaking chat. + */ +export class StorageUnavailableError extends Error { + constructor(storageName: StorageName) { + super(`${storageName} is not available in this environment.`) + this.name = 'StorageUnavailableError' + } +} + +function stringifyJson(value: TValue): string { + const stringify: (input: unknown) => unknown = JSON.stringify + const serialized = stringify(value) + if (typeof serialized !== 'string') { + throw new TypeError('The value is not JSON serializable.') + } + return serialized +} + +function createWebStoragePersistence( + storageName: 'localStorage' | 'sessionStorage', + options: WebStoragePersistenceOptions, +): ChatStorageAdapter { + const keyPrefix = options.keyPrefix ?? 'tanstack-ai:' + const serialize = options.serialize ?? stringifyJson + const deserialize = options.deserialize ?? JSON.parse + const key = (id: string) => `${keyPrefix}${id}` + + const getStorage = (): Storage => { + const browserGlobals: { + localStorage?: Storage + sessionStorage?: Storage + } = globalThis + const storage = browserGlobals[storageName] + if (!storage) { + throw new StorageUnavailableError(storageName) + } + return storage + } + + return { + getItem(id) { + const item = getStorage().getItem(key(id)) + return item === null ? null : deserialize(item) + }, + setItem(id, value) { + getStorage().setItem(key(id), serialize(value)) + }, + removeItem(id) { + getStorage().removeItem(key(id)) + }, + } +} + +/** + * A `ChatStorageAdapter` backed by `window.localStorage` (persists across + * reloads and browser restarts). Keys are namespaced with `keyPrefix`, which + * defaults to `tanstack-ai:`. Every operation reads `localStorage` lazily and + * throws {@link StorageUnavailableError} when it is absent (e.g. SSR), so the + * adapter can be constructed safely on the server. + * + * The `serialize` / `deserialize` codec defaults to `JSON.stringify` / + * `JSON.parse`, so the common case needs no codec. `TValue` defaults to + * {@link ChatPersistedState}, so `localStoragePersistence()` drops straight into + * the `persistence` option with no type argument. Pass a codec only for values + * JSON can't round-trip losslessly, and a type argument for non-chat storage. + */ +export function localStoragePersistence( + options: WebStoragePersistenceOptions = {}, +): ChatStorageAdapter { + return createWebStoragePersistence('localStorage', options) +} + +/** + * A `ChatStorageAdapter` backed by `window.sessionStorage` (scoped to the tab + * and cleared when it closes). Identical to {@link localStoragePersistence} in + * every other respect: `ChatPersistedState` default `TValue`, `tanstack-ai:` + * default `keyPrefix`, lazy per-operation {@link StorageUnavailableError} on + * SSR, and a JSON codec that defaults to `JSON.stringify` / `JSON.parse`. + */ +export function sessionStoragePersistence( + options: WebStoragePersistenceOptions = {}, +): ChatStorageAdapter { + return createWebStoragePersistence('sessionStorage', options) +} + +/** + * A `ChatStorageAdapter` backed by IndexedDB, for values too large for Web + * Storage or that benefit from structured-clone storage. All operations are + * async and the database opens lazily on first use; keys are namespaced with + * `keyPrefix` (default `tanstack-ai:`). When IndexedDB is unavailable (e.g. + * SSR) each operation rejects with {@link StorageUnavailableError}. + * + * No serialize/deserialize codec is needed or accepted — values are stored via + * IndexedDB's native structured clone, so `Date`, `Map`, `ArrayBuffer`, etc. + * round-trip without a JSON step. `TValue` defaults to {@link ChatPersistedState}. + */ +export function indexedDBPersistence( + options: IndexedDBPersistenceOptions = {}, +): ChatStorageAdapter { + const databaseName = options.databaseName ?? 'tanstack-ai' + const objectStoreName = options.objectStoreName ?? 'persistence' + const keyPrefix = options.keyPrefix ?? 'tanstack-ai:' + let databasePromise: Promise | undefined + + const openDatabase = (): Promise => { + if (databasePromise) { + return databasePromise + } + + databasePromise = new Promise((resolve, reject) => { + const browserGlobals: { indexedDB?: IDBFactory } = globalThis + const factory = browserGlobals.indexedDB + if (!factory) { + reject(new StorageUnavailableError('indexedDB')) + return + } + + let request: IDBOpenDBRequest + let openFailed = false + try { + request = factory.open(databaseName) + } catch (error) { + reject(error) + return + } + + request.onupgradeneeded = () => { + if (!request.result.objectStoreNames.contains(objectStoreName)) { + request.result.createObjectStore(objectStoreName) + } + } + request.onerror = () => { + openFailed = true + reject(request.error ?? new Error(`Failed to open ${databaseName}.`)) + } + request.onblocked = () => { + openFailed = true + reject( + new Error( + `Opening IndexedDB database "${databaseName}" was blocked.`, + ), + ) + } + request.onsuccess = () => { + const database = request.result + if (openFailed) { + database.close() + return + } + database.onversionchange = () => { + database.close() + databasePromise = undefined + } + resolve(database) + } + }).catch((error: unknown) => { + databasePromise = undefined + throw error + }) + + return databasePromise + } + + const runRequest = async ( + mode: IDBTransactionMode, + createRequest: (store: IDBObjectStore) => IDBRequest, + ): Promise => { + const database = await openDatabase() + return new Promise((resolve, reject) => { + let request: IDBRequest + let result: TResult + try { + const transaction = database.transaction(objectStoreName, mode) + request = createRequest(transaction.objectStore(objectStoreName)) + request.onsuccess = () => { + result = request.result + } + request.onerror = () => { + reject(request.error ?? new Error('IndexedDB request failed.')) + } + transaction.oncomplete = () => { + resolve(result) + } + transaction.onerror = () => { + reject( + transaction.error ?? new Error('IndexedDB transaction failed.'), + ) + } + transaction.onabort = () => { + reject( + transaction.error ?? new Error('IndexedDB transaction aborted.'), + ) + } + } catch (error) { + reject(error) + } + }) + } + + const key = (id: string) => `${keyPrefix}${id}` + return { + getItem(id) { + return runRequest('readonly', (store) => store.get(key(id))) + }, + setItem(id, value) { + return runRequest('readwrite', (store) => store.put(value, key(id))).then( + () => undefined, + ) + }, + removeItem(id) { + return runRequest('readwrite', (store) => store.delete(key(id))).then( + () => undefined, + ) + }, + } +} diff --git a/packages/ai-client/src/types.ts b/packages/ai-client/src/types.ts index 57a3fdd2e..2613a9b46 100644 --- a/packages/ai-client/src/types.ts +++ b/packages/ai-client/src/types.ts @@ -553,23 +553,91 @@ export interface UIMessage< createdAt?: Date } +/** + * A generic key/value storage adapter. `getItem` may be sync or async; the + * chat persistence layer treats every call as best-effort. The provided + * `localStoragePersistence` / `sessionStoragePersistence` / `indexedDBPersistence` + * factories return one of these, and `ChatStorageAdapter` + * is assignable to {@link ChatClientPersistence}. + */ +export interface ChatStorageAdapter { + getItem: ( + id: string, + ) => TValue | null | undefined | Promise + setItem: (id: string, value: TValue) => void | Promise + removeItem: (id: string) => void | Promise +} + +/** + * The single record a `ChatClientPersistence` adapter stores per chat. It folds + * the two things that must survive a full page reload into one blob under one + * key: the message transcript and the optional resume snapshot (which run to + * rejoin / which interrupts to rehydrate). One adapter, one key — see + * {@link ChatClientPersistence}. + */ +export interface ChatPersistedState< + TTools extends ReadonlyArray = any, +> { + messages: Array> + /** Present while a run is in flight or paused on an interrupt; absent otherwise. */ + resume?: ChatResumeSnapshot +} + +/** + * Storage adapter for durable chat state. A single adapter persists both the + * message transcript and the resume snapshot as one {@link ChatPersistedState} + * record, so a full page reload restores the conversation AND can rejoin an + * in-flight run / rehydrate pending interrupts. + * + * For backward compatibility `getItem` may also return a bare `UIMessage[]` + * (the legacy messages-only format); the client normalizes it to + * `{ messages }`. `setItem` always writes the combined record. + */ export interface ChatClientPersistence< TTools extends ReadonlyArray = any, > { getItem: ( id: string, ) => + | ChatPersistedState | Array> | null | undefined - | Promise> | null | undefined> + | Promise< + ChatPersistedState | Array> | null | undefined + > setItem: ( id: string, - messages: Array>, + state: ChatPersistedState, ) => void | Promise removeItem: (id: string) => void | Promise } +/** + * Persistence configuration for the `persistence` option. Pass a bare + * {@link ChatClientPersistence} adapter to persist everything (the transcript + * plus the resume pointer), or this object form to pull the message lever. + * + * `messages: false` keeps the transcript OFF the client and persists only the + * tiny resume pointer (which run to rejoin, which interrupts are pending), so + * durability rejoin and interrupt restore still work while the server stays the + * authoritative source of history. Big conversations then never hit + * localStorage, avoiding the quota and parse cost. Defaults to `true` + * (transcript cached client-side). + */ +export interface ChatPersistenceConfig< + TTools extends ReadonlyArray = any, +> { + store: ChatClientPersistence + /** Cache the transcript client-side. Default `true`. `false` = resume pointer only. */ + messages?: boolean +} + +/** The `persistence` option: a bare adapter, or `{ store, messages? }`. */ +export type ChatPersistenceOption< + TTools extends ReadonlyArray = any, +> = ChatClientPersistence | ChatPersistenceConfig + type IsUnknown = unknown extends T ? [T] extends [unknown] ? true @@ -661,21 +729,33 @@ export interface ChatClientBaseOptions< initialMessages?: Array> /** - * Optional persistence adapter for chat messages (UIMessage[] by chat id). - * Durable interrupt resume storage is not part of this surface — use - * `initialResumeSnapshot` for in-memory rehydrate after a host-managed load. + * Optional persistence for durable chat state, keyed by chat id. Pass a + * {@link ChatClientPersistence} adapter to store the combined + * {@link ChatPersistedState} record (messages + resume pointer), so a full + * page reload restores the transcript, rehydrates pending interrupts, and + * rejoins an in-flight run. + * + * To keep large transcripts off the client, pass `{ store, messages: false }` + * ({@link ChatPersistenceConfig}) instead: only the tiny resume pointer is + * cached, durability rejoin and interrupt restore still work, and the server + * is the authoritative source of history. Use `initialResumeSnapshot` for a + * host-supplied in-memory rehydrate instead. */ - persistence?: ChatClientPersistence + persistence?: ChatPersistenceOption /** - * Unique identifier for this chat instance - * Used for managing multiple chats + * Optional storage-key override for this chat instance, and the devtools + * instance id. Persistence keys on `threadId` by default; set `id` only when + * you need the persisted record keyed separately from the wire thread. + * Prefer a stable `threadId` for the common case. */ id?: string /** - * Thread ID to use for this chat session. Persists across sends within - * the session. If omitted, a unique thread ID is generated. + * The conversation id for this chat, stable across sends and reloads. It is + * the AG-UI thread key on the wire AND the key client persistence stores the + * conversation under, so set a stable `threadId` to have a reload restore the + * same conversation. If omitted, a unique thread id is generated per session. */ threadId?: string diff --git a/packages/ai-client/tests/chat-client.test.ts b/packages/ai-client/tests/chat-client.test.ts index 7f86c4858..05046d076 100644 --- a/packages/ai-client/tests/chat-client.test.ts +++ b/packages/ai-client/tests/chat-client.test.ts @@ -14,7 +14,11 @@ import type { ConnectionAdapter, } from '../src/connection-adapters' import type { ModelMessage, StreamChunk } from '@tanstack/ai/client' -import type { ChatClientPersistence, UIMessage } from '../src/types' +import type { + ChatClientPersistence, + ChatPersistedState, + UIMessage, +} from '../src/types' describe('ChatClient', () => { const persistedMessage: UIMessage = { @@ -419,8 +423,8 @@ describe('ChatClient', () => { } const persistence = { getItem: vi.fn(() => undefined), - setItem: vi.fn((_key: string, messages: Array) => { - storedMessages = messages + setItem: vi.fn((_key: string, state: ChatPersistedState) => { + storedMessages = state.messages }), removeItem: vi.fn(() => { storedMessages = undefined @@ -514,8 +518,8 @@ describe('ChatClient', () => { } const persistence = { getItem: vi.fn(() => undefined), - setItem: vi.fn((_key: string, messages: Array) => { - storedMessages = messages + setItem: vi.fn((_key: string, state: ChatPersistedState) => { + storedMessages = state.messages }), removeItem: vi.fn(() => { storedMessages = undefined @@ -648,8 +652,8 @@ describe('ChatClient', () => { } const persistence = { getItem: vi.fn(() => undefined), - setItem: vi.fn((_key: string, messages: Array) => { - storedMessages = messages + setItem: vi.fn((_key: string, state: ChatPersistedState) => { + storedMessages = state.messages }), removeItem: vi.fn(() => { storedMessages = undefined @@ -852,8 +856,8 @@ describe('ChatClient', () => { } const persistence = { getItem: vi.fn(() => undefined), - setItem: vi.fn((_key: string, messages: Array) => { - storedMessages = messages + setItem: vi.fn((_key: string, state: ChatPersistedState) => { + storedMessages = state.messages }), removeItem: vi.fn(() => { storedMessages = undefined @@ -905,8 +909,8 @@ describe('ChatClient', () => { } const persistence = { getItem: vi.fn(() => undefined), - setItem: vi.fn((_key: string, messages: Array) => { - storedMessages = messages + setItem: vi.fn((_key: string, state: ChatPersistedState) => { + storedMessages = state.messages }), removeItem: vi.fn(() => { storedMessages = undefined @@ -1023,8 +1027,8 @@ describe('ChatClient', () => { } const persistence = { getItem: vi.fn(() => undefined), - setItem: vi.fn((_key: string, messages: Array) => { - storedMessages = messages + setItem: vi.fn((_key: string, state: ChatPersistedState) => { + storedMessages = state.messages }), removeItem: vi.fn(() => { storedMessages = undefined @@ -1377,8 +1381,8 @@ describe('ChatClient', () => { } const persistence = { getItem: vi.fn(() => undefined), - setItem: vi.fn((_key: string, messages: Array) => { - storedMessages = messages + setItem: vi.fn((_key: string, state: ChatPersistedState) => { + storedMessages = state.messages }), removeItem: vi.fn(() => { storedMessages = undefined @@ -2582,8 +2586,8 @@ describe('ChatClient', () => { } const persistence = { getItem: vi.fn(() => undefined), - setItem: vi.fn((_key: string, messages: Array) => { - storedMessages = messages + setItem: vi.fn((_key: string, state: ChatPersistedState) => { + storedMessages = state.messages }), removeItem: vi.fn(() => { storedMessages = undefined @@ -2631,9 +2635,9 @@ describe('ChatClient', () => { const releaseSet = createDeferred() const persistence = { getItem: vi.fn(() => undefined), - setItem: vi.fn(async (_key: string, messages: Array) => { + setItem: vi.fn(async (_key: string, state: ChatPersistedState) => { await releaseSet.promise - storedMessages = messages + storedMessages = state.messages }), removeItem: vi.fn(() => { storedMessages = undefined @@ -2671,10 +2675,9 @@ describe('ChatClient', () => { await client.sendMessage('Hello') expect(persistence.setItem).toHaveBeenCalled() - expect(persistence.setItem).toHaveBeenLastCalledWith( - 'chat-1', - client.getMessages(), - ) + expect(persistence.setItem).toHaveBeenLastCalledWith('chat-1', { + messages: client.getMessages(), + }) }) it('should save message snapshots when messages are set manually', () => { @@ -2688,9 +2691,9 @@ describe('ChatClient', () => { client.setMessagesManually([initialMessage]) - expect(persistence.setItem).toHaveBeenCalledWith('chat-1', [ - initialMessage, - ]) + expect(persistence.setItem).toHaveBeenCalledWith('chat-1', { + messages: [initialMessage], + }) }) it('should swallow async persistence write and remove failures', async () => { diff --git a/packages/ai-client/tests/client-persistor.test.ts b/packages/ai-client/tests/client-persistor.test.ts index 05cc4380c..1ff05ac3f 100644 --- a/packages/ai-client/tests/client-persistor.test.ts +++ b/packages/ai-client/tests/client-persistor.test.ts @@ -3,7 +3,11 @@ import { EventType } from '@tanstack/ai/client' import { ChatPersistor } from '../src/client-persistor' import { createMockPersistence, createUIMessage } from './test-utils' import type { StreamChunk } from '@tanstack/ai/client' -import type { ChatClientPersistence, UIMessage } from '../src/types' +import type { + ChatClientPersistence, + ChatPersistedState, + UIMessage, +} from '../src/types' const CHAT_ID = 'chat-1' @@ -82,7 +86,8 @@ describe('ChatPersistor', () => { const adapter = createMockPersistence(stored) const { persistor } = createPersistor(adapter) - expect(persistor.readInitial()).toBe(stored) + // A legacy bare-array record is normalized to the combined shape. + expect(persistor.readInitial()).toEqual({ messages: stored }) expect(adapter.getItem).toHaveBeenCalledWith(CHAT_ID) }) @@ -107,48 +112,42 @@ describe('ChatPersistor', () => { }) describe('hydrateAsync', () => { - it('applies messages once the promise resolves to an array', async () => { + it('applies messages once the promise resolves to a record', async () => { const stored = [createUIMessage('persisted-1')] const { persistor, applyMessages } = createPersistor( createMockPersistence(), ) - persistor.hydrateAsync(Promise.resolve(stored)) + persistor.hydrateAsync(Promise.resolve({ messages: stored })) await flushAsync() expect(applyMessages).toHaveBeenCalledWith(stored) }) - it.each([ - ['null', null], - ['undefined', undefined], - ])( - 'does not apply when the promise resolves to %s', - async (_label, value) => { - const { persistor, applyMessages } = createPersistor( - createMockPersistence(), - ) + it('does not apply when the promise resolves to undefined', async () => { + const { persistor, applyMessages } = createPersistor( + createMockPersistence(), + ) - persistor.hydrateAsync(Promise.resolve(value)) - await flushAsync() + persistor.hydrateAsync(Promise.resolve(undefined)) + await flushAsync() - expect(applyMessages).not.toHaveBeenCalled() - }, - ) + expect(applyMessages).not.toHaveBeenCalled() + }) it('does nothing for a synchronous (non-promise) value', async () => { const { persistor, applyMessages } = createPersistor( createMockPersistence(), ) - persistor.hydrateAsync([createUIMessage('m-1')]) + persistor.hydrateAsync({ messages: [createUIMessage('m-1')] }) await flushAsync() expect(applyMessages).not.toHaveBeenCalled() }) it('does not apply if messages changed before hydration resolves', async () => { - const deferred = createDeferred>() + const deferred = createDeferred() const { persistor, applyMessages } = createPersistor( createMockPersistence(), ) @@ -156,7 +155,7 @@ describe('ChatPersistor', () => { persistor.hydrateAsync(deferred.promise) // A local change lands before the slow getItem resolves. persistor.notifyMessagesChanged([createUIMessage('local-1')]) - deferred.resolve([createUIMessage('persisted-1')]) + deferred.resolve({ messages: [createUIMessage('persisted-1')] }) await flushAsync() expect(applyMessages).not.toHaveBeenCalled() @@ -182,7 +181,7 @@ describe('ChatPersistor', () => { persistor.notifyMessagesChanged(messages) - expect(adapter.setItem).toHaveBeenCalledWith(CHAT_ID, messages) + expect(adapter.setItem).toHaveBeenCalledWith(CHAT_ID, { messages }) }) it('persists a snapshot, not the live array reference', () => { @@ -194,7 +193,7 @@ describe('ChatPersistor', () => { messages.push(createUIMessage('m-2')) const persisted = vi.mocked(adapter.setItem).mock.calls[0]?.[1] - expect(persisted).toHaveLength(1) + expect(persisted?.messages).toHaveLength(1) }) it('skips exactly one write after beginClear, then resumes', () => { @@ -240,9 +239,9 @@ describe('ChatPersistor', () => { await flushAsync() expect(adapter.setItem).toHaveBeenCalledTimes(2) - expect(vi.mocked(adapter.setItem).mock.calls[1]?.[1]).toEqual([ - createUIMessage('b'), - ]) + expect(vi.mocked(adapter.setItem).mock.calls[1]?.[1]).toEqual({ + messages: [createUIMessage('b')], + }) }) it('keeps writing after an async write rejects', async () => { diff --git a/packages/ai-client/tests/resume-snapshot.test.ts b/packages/ai-client/tests/resume-snapshot.test.ts new file mode 100644 index 000000000..502209bf4 --- /dev/null +++ b/packages/ai-client/tests/resume-snapshot.test.ts @@ -0,0 +1,406 @@ +import { describe, expect, it, vi } from 'vitest' +import { ChatPersistor } from '../src/client-persistor' +import { normalizeConnectionAdapter } from '../src/connection-adapters' +import { ChatClient } from '../src/chat-client' +import { localStoragePersistence } from '../src/storage-adapters' +import { createUIMessage } from './test-utils' +import type { + ChatClientPersistence, + ChatPersistedState, + ChatResumeSnapshot, + UIMessage, +} from '../src/types' +import type { + ResumableConnectConnectionAdapter, + RunAgentInputContext, +} from '../src/connection-adapters' +import type { StreamChunk } from '@tanstack/ai/client' + +/** An in-memory store capturing the last combined record written. */ +function memoryAdapter(initial?: ChatPersistedState | Array): { + adapter: ChatClientPersistence + read: () => ChatPersistedState | Array | undefined +} { + let value = initial + return { + adapter: { + getItem: () => value, + setItem: (_id, state) => { + value = state + }, + removeItem: () => { + value = undefined + }, + }, + read: () => value, + } +} + +describe('ChatPersistor combined record', () => { + it('writes messages and resume snapshot as one record', () => { + const { adapter, read } = memoryAdapter() + const persistor = new ChatPersistor(adapter, 'chat-1', () => {}) + + persistor.notifyMessagesChanged([createUIMessage('m1', 'hello')]) + const snapshot: ChatResumeSnapshot = { + schemaVersion: 2, + resumeState: { threadId: 't1', runId: 'r1' }, + } + persistor.persistResumeSnapshot(snapshot) + + const stored = read() as ChatPersistedState + expect(stored.messages).toHaveLength(1) + expect(stored.resume?.resumeState.runId).toBe('r1') + }) + + it('clears the resume snapshot but keeps messages', () => { + const { adapter, read } = memoryAdapter() + const persistor = new ChatPersistor(adapter, 'chat-1', () => {}) + persistor.notifyMessagesChanged([createUIMessage('m1', 'hello')]) + persistor.persistResumeSnapshot({ + schemaVersion: 2, + resumeState: { threadId: 't1', runId: 'r1' }, + }) + persistor.persistResumeSnapshot(null) + + const stored = read() as ChatPersistedState + expect(stored.messages).toHaveLength(1) + expect(stored.resume).toBeUndefined() + }) + + it('normalizes a legacy bare-array record on read', () => { + const applied: Array> = [] + const { adapter } = memoryAdapter([createUIMessage('m1', 'legacy')]) + const persistor = new ChatPersistor(adapter, 'chat-1', (m) => + applied.push(m), + ) + const state = persistor.readInitial() as ChatPersistedState + expect(state.messages[0]?.id).toBe('m1') + expect(state.resume).toBeUndefined() + }) + + it('with storeMessages=false persists only the resume pointer', () => { + const { adapter, read } = memoryAdapter() + // storeMessages=false via the 5th constructor arg. + const persistor = new ChatPersistor( + adapter, + 'chat-1', + () => {}, + undefined, + false, + ) + persistor.notifyMessagesChanged([createUIMessage('m1', 'heavy history')]) + persistor.persistResumeSnapshot({ + schemaVersion: 2, + resumeState: { threadId: 't1', runId: 'r1' }, + }) + const stored = read() as ChatPersistedState + // Transcript stays off the client; the tiny resume pointer is kept so + // durability rejoin still works. + expect(stored.messages).toEqual([]) + expect(stored.resume?.resumeState.runId).toBe('r1') + }) +}) + +describe('ChatClient persistence option shapes', () => { + it('accepts { store, messages: false } and keeps messages off the client', () => { + const { adapter, read } = memoryAdapter() + const client = new ChatClient({ + id: 'chat-cfg', + threadId: 't1', + connection: { connect: async function* () {} }, + persistence: { store: adapter, messages: false }, + initialMessages: [createUIMessage('seed', 'hi', 'user')], + }) + // A message change should not write the transcript into the record. + void client + const stored = read() + if (stored && !Array.isArray(stored)) { + expect(stored.messages).toEqual([]) + } + }) +}) + +describe('localStoragePersistence ergonomics', () => { + it('needs no type arg or codec and round-trips a ChatPersistedState', () => { + // Minimal in-memory Storage stub so the test doesn't depend on a DOM env. + const map = new Map() + const stub = { + getItem: (k: string) => map.get(k) ?? null, + setItem: (k: string, v: string) => void map.set(k, v), + removeItem: (k: string) => void map.delete(k), + } + const globals = globalThis as { localStorage?: unknown } + const previous = globals.localStorage + globals.localStorage = stub + try { + // The headline call: no generic, no serialize/deserialize. + const store = localStoragePersistence() + const record: ChatPersistedState = { + messages: [createUIMessage('m1', 'hi')], + resume: { + schemaVersion: 2, + resumeState: { threadId: 't1', runId: 'r1' }, + }, + } + store.setItem('chat-1', record) + const read = store.getItem('chat-1') + expect(read && !(read instanceof Promise) && read.messages[0]?.id).toBe( + 'm1', + ) + } finally { + globals.localStorage = previous + } + }) +}) + +describe('normalizeConnectionAdapter joinRun passthrough', () => { + it('exposes joinRun when the connection is resumable', () => { + const joinRun = vi.fn(async function* () { + // empty + }) + const resumable: ResumableConnectConnectionAdapter = { + connect: async function* () {}, + joinRun, + } + const normalized = normalizeConnectionAdapter(resumable) + expect(typeof normalized.joinRun).toBe('function') + }) + + it('omits joinRun for a plain connect adapter', () => { + const normalized = normalizeConnectionAdapter({ + connect: async function* () {}, + }) + expect(normalized.joinRun).toBeUndefined() + }) +}) + +function runChunks(runId: string, threadId: string): Array { + return [ + { type: 'RUN_STARTED', runId, threadId, timestamp: 1 } as StreamChunk, + { + type: 'TEXT_MESSAGE_START', + messageId: 'assistant-1', + role: 'assistant', + timestamp: 2, + } as StreamChunk, + { + type: 'TEXT_MESSAGE_CONTENT', + messageId: 'assistant-1', + delta: 'world', + content: 'world', + timestamp: 3, + } as StreamChunk, + { + type: 'TEXT_MESSAGE_END', + messageId: 'assistant-1', + timestamp: 4, + } as StreamChunk, + { + type: 'RUN_FINISHED', + runId, + threadId, + timestamp: 5, + finishReason: 'stop', + } as StreamChunk, + ] +} + +describe('ChatClient auto-rejoin after reload', () => { + it('rejoins a persisted in-flight run via joinRun', async () => { + // A store pre-seeded as if a previous session persisted a live run. + const { adapter } = memoryAdapter({ + messages: [createUIMessage('user-1', 'hi', 'user')], + resume: { + schemaVersion: 2, + resumeState: { threadId: 't1', runId: 'r1' }, + }, + }) + + const joinRun = vi.fn( + // eslint-disable-next-line require-yield + async function* (_runId: string) { + for (const chunk of runChunks('r1', 't1')) { + yield chunk + } + }, + ) + const connection: ResumableConnectConnectionAdapter = { + connect: async function* ( + _messages, + _data?: Record, + _signal?: AbortSignal, + _ctx?: RunAgentInputContext, + ) {}, + joinRun, + } + + let latest: Array = [] + const client = new ChatClient({ + id: 'chat-1', + threadId: 't1', + connection, + persistence: adapter, + onMessagesChange: (messages) => { + latest = messages + }, + }) + + // Rejoin is async; wait for the replayed run to finish. + await vi.waitFor(() => { + const assistant = latest.find((m) => m.role === 'assistant') + const text = assistant?.parts.find((p) => p.type === 'text') + expect(text && 'content' in text && text.content).toBe('world') + }) + + expect(joinRun).toHaveBeenCalledWith('r1', expect.anything()) + // The restored user message survives alongside the rejoined assistant reply. + expect(latest.some((m) => m.id === 'user-1')).toBe(true) + void client + }) + + it('messages:false reconstructs history AND rejoins a live run together', async () => { + // A prior server-authoritative session persisted only the resume pointer: + // messages is [] (not cached), resume carries the in-flight runId. + const { adapter } = memoryAdapter({ + messages: [], + resume: { + schemaVersion: 2, + resumeState: { threadId: 't1', runId: 'r1' }, + }, + }) + + const joinRun = vi.fn(async function* (_runId: string) { + for (const chunk of runChunks('r1', 't1')) { + yield chunk + } + }) + const connection: ResumableConnectConnectionAdapter = { + connect: async function* () {}, + joinRun, + } + + let latest: Array = [] + const client = new ChatClient({ + threadId: 't1', + connection, + persistence: { store: adapter, messages: false }, + // History the app fetched from the server (reconstructChat) and seeded. + initialMessages: [createUIMessage('history-1', 'earlier turn', 'user')], + onMessagesChange: (messages) => { + latest = messages + }, + }) + + await vi.waitFor(() => { + const assistant = latest.find((m) => m.role === 'assistant') + const text = assistant?.parts.find((p) => p.type === 'text') + expect(text && 'content' in text && text.content).toBe('world') + }) + + // Server-reconstructed history is NOT wiped by the empty persisted record... + expect(latest.some((m) => m.id === 'history-1')).toBe(true) + // ...and the live run was rejoined off the durability log. + expect(joinRun).toHaveBeenCalledWith('r1', expect.anything()) + void client + }) + + it('rebuilds a hydrated in-flight partial in place (no duplicate) on rejoin', async () => { + // Server-authoritative reload where a streaming snapshot persisted a PARTIAL + // assistant reply carrying the same messageId the live run uses. The rejoin + // must rebuild it from the log into ONE clean bubble — not seed+append into + // "worworld", and not leave a second bubble. + const { adapter } = memoryAdapter({ + messages: [], + resume: { + schemaVersion: 2, + resumeState: { threadId: 't1', runId: 'r1' }, + }, + }) + + const joinRun = vi.fn(async function* (_runId: string) { + for (const chunk of runChunks('r1', 't1')) yield chunk + }) + const connection: ResumableConnectConnectionAdapter = { + connect: async function* () {}, + joinRun, + } + + let latest: Array = [] + const client = new ChatClient({ + threadId: 't1', + connection, + persistence: { store: adapter, messages: false }, + initialMessages: [ + createUIMessage('user-1', 'hi', 'user'), + { + id: 'assistant-1', + role: 'assistant', + parts: [{ type: 'text', content: 'wor' }], + createdAt: new Date(), + }, + ], + onMessagesChange: (messages) => { + latest = messages + }, + }) + + await vi.waitFor(() => { + const assistant = latest.find((m) => m.role === 'assistant') + const text = assistant?.parts.find((p) => p.type === 'text') + expect(text && 'content' in text && text.content).toBe('world') + }) + + const assistants = latest.filter((m) => m.role === 'assistant') + expect(assistants).toHaveLength(1) + const text = assistants[0]?.parts.find((p) => p.type === 'text') + expect(text && 'content' in text && text.content).toBe('world') + expect(latest.some((m) => m.id === 'user-1')).toBe(true) + void client + }) + + it('rejoins from an async store (getItem returns a Promise)', async () => { + // An async adapter (like indexedDBPersistence): readInitial resolves later, + // so the rejoin must come from the async hydrate path, not the sync read. + const record: ChatPersistedState = { + messages: [], + resume: { + schemaVersion: 2, + resumeState: { threadId: 't1', runId: 'r1' }, + }, + } + const asyncAdapter: ChatClientPersistence = { + getItem: () => Promise.resolve(record), + setItem: () => Promise.resolve(), + removeItem: () => Promise.resolve(), + } + + const joinRun = vi.fn(async function* (_runId: string) { + for (const chunk of runChunks('r1', 't1')) { + yield chunk + } + }) + const connection: ResumableConnectConnectionAdapter = { + connect: async function* () {}, + joinRun, + } + + let latest: Array = [] + const client = new ChatClient({ + threadId: 't1', + connection, + persistence: asyncAdapter, + onMessagesChange: (messages) => { + latest = messages + }, + }) + + await vi.waitFor(() => { + const assistant = latest.find((m) => m.role === 'assistant') + const text = assistant?.parts.find((p) => p.type === 'text') + expect(text && 'content' in text && text.content).toBe('world') + }) + expect(joinRun).toHaveBeenCalledWith('r1', expect.anything()) + void client + }) +}) diff --git a/packages/ai-persistence-cloudflare/bin/tanstack-ai-cloudflare-migrations.mjs b/packages/ai-persistence-cloudflare/bin/tanstack-ai-cloudflare-migrations.mjs new file mode 100644 index 000000000..0a4de6588 --- /dev/null +++ b/packages/ai-persistence-cloudflare/bin/tanstack-ai-cloudflare-migrations.mjs @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import '../dist/esm/cli.js' diff --git a/packages/ai-persistence-cloudflare/migrations/0000_tanstack_ai_initial.sql b/packages/ai-persistence-cloudflare/migrations/0000_tanstack_ai_initial.sql new file mode 100644 index 000000000..3e5570ec1 --- /dev/null +++ b/packages/ai-persistence-cloudflare/migrations/0000_tanstack_ai_initial.sql @@ -0,0 +1,32 @@ +CREATE TABLE `interrupts` ( + `interrupt_id` text PRIMARY KEY NOT NULL, + `run_id` text NOT NULL, + `thread_id` text NOT NULL, + `status` text NOT NULL, + `requested_at` integer NOT NULL, + `resolved_at` integer, + `payload_json` text NOT NULL, + `response_json` text +); +--> statement-breakpoint +CREATE TABLE `messages` ( + `thread_id` text PRIMARY KEY NOT NULL, + `messages_json` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `metadata` ( + `scope` text NOT NULL, + `key` text NOT NULL, + `value_json` text NOT NULL, + PRIMARY KEY(`scope`, `key`) +); +--> statement-breakpoint +CREATE TABLE `runs` ( + `run_id` text PRIMARY KEY NOT NULL, + `thread_id` text NOT NULL, + `status` text NOT NULL, + `started_at` integer NOT NULL, + `finished_at` integer, + `error` text, + `usage_json` text +); diff --git a/packages/ai-persistence-cloudflare/package.json b/packages/ai-persistence-cloudflare/package.json new file mode 100644 index 000000000..bdd3237c0 --- /dev/null +++ b/packages/ai-persistence-cloudflare/package.json @@ -0,0 +1,64 @@ +{ + "name": "@tanstack/ai-persistence-cloudflare", + "version": "0.0.0", + "description": "Cloudflare D1 and Durable Object state persistence for TanStack AI.", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-persistence-cloudflare" + }, + "keywords": [ + "ai", + "tanstack", + "persistence", + "cloudflare", + "d1", + "durable-objects" + ], + "type": "module", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + } + }, + "bin": { + "tanstack-ai-cloudflare-migrations": "./bin/tanstack-ai-cloudflare-migrations.mjs" + }, + "files": [ + "bin", + "dist", + "migrations", + "src" + ], + "scripts": { + "build": "vite build", + "clean": "premove ./build ./dist", + "lint:fix": "oxlint src --type-aware --fix", + "test:build": "publint --strict", + "test:lib": "vitest", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc", + "test:oxlint": "oxlint src --type-aware" + }, + "peerDependencies": { + "@cloudflare/workers-types": ">=4.20260317.1", + "@tanstack/ai": "workspace:^", + "@tanstack/ai-persistence": "workspace:^", + "@tanstack/ai-persistence-drizzle": "workspace:^", + "drizzle-orm": ">=0.44.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@tanstack/ai": "workspace:*", + "@tanstack/ai-persistence": "workspace:*", + "@tanstack/ai-persistence-drizzle": "workspace:*", + "@vitest/coverage-v8": "4.0.14", + "drizzle-orm": "^0.45.0", + "miniflare": "^4.20260609.0" + } +} diff --git a/packages/ai-persistence-cloudflare/src/assets.d.ts b/packages/ai-persistence-cloudflare/src/assets.d.ts new file mode 100644 index 000000000..b04692701 --- /dev/null +++ b/packages/ai-persistence-cloudflare/src/assets.d.ts @@ -0,0 +1,4 @@ +declare module '*.sql?raw' { + const contents: string + export default contents +} diff --git a/packages/ai-persistence-cloudflare/src/assets/0000_tanstack_ai_initial.sql b/packages/ai-persistence-cloudflare/src/assets/0000_tanstack_ai_initial.sql new file mode 100644 index 000000000..3e5570ec1 --- /dev/null +++ b/packages/ai-persistence-cloudflare/src/assets/0000_tanstack_ai_initial.sql @@ -0,0 +1,32 @@ +CREATE TABLE `interrupts` ( + `interrupt_id` text PRIMARY KEY NOT NULL, + `run_id` text NOT NULL, + `thread_id` text NOT NULL, + `status` text NOT NULL, + `requested_at` integer NOT NULL, + `resolved_at` integer, + `payload_json` text NOT NULL, + `response_json` text +); +--> statement-breakpoint +CREATE TABLE `messages` ( + `thread_id` text PRIMARY KEY NOT NULL, + `messages_json` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `metadata` ( + `scope` text NOT NULL, + `key` text NOT NULL, + `value_json` text NOT NULL, + PRIMARY KEY(`scope`, `key`) +); +--> statement-breakpoint +CREATE TABLE `runs` ( + `run_id` text PRIMARY KEY NOT NULL, + `thread_id` text NOT NULL, + `status` text NOT NULL, + `started_at` integer NOT NULL, + `finished_at` integer, + `error` text, + `usage_json` text +); diff --git a/packages/ai-persistence-cloudflare/src/bindings.ts b/packages/ai-persistence-cloudflare/src/bindings.ts new file mode 100644 index 000000000..cdf01afac --- /dev/null +++ b/packages/ai-persistence-cloudflare/src/bindings.ts @@ -0,0 +1,24 @@ +export interface DurableObjectStubBinding { + fetch: ( + input: Request | string | URL, + init?: RequestInit, + ) => Promise +} + +/** The Durable Object namespace surface used by the lock client. */ +export interface DurableObjectNamespaceBinding { + idFromName: (name: string) => TId + get: (id: TId) => DurableObjectStubBinding +} + +export interface LockDurableObjectStorage { + get: (key: string) => Promise + put: (key: string, value: unknown) => Promise + delete: (key: string) => Promise + setAlarm: (timestamp: number) => Promise + deleteAlarm: () => Promise +} + +export interface LockDurableObjectState { + storage: LockDurableObjectStorage +} diff --git a/packages/ai-persistence-cloudflare/src/cli.ts b/packages/ai-persistence-cloudflare/src/cli.ts new file mode 100644 index 000000000..6d17f2681 --- /dev/null +++ b/packages/ai-persistence-cloudflare/src/cli.ts @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { runCloudflareMigrationsCli } from './migration-cli' + +await runCloudflareMigrationsCli(process.argv.slice(2)) diff --git a/packages/ai-persistence-cloudflare/src/d1.ts b/packages/ai-persistence-cloudflare/src/d1.ts new file mode 100644 index 000000000..011e70ce4 --- /dev/null +++ b/packages/ai-persistence-cloudflare/src/d1.ts @@ -0,0 +1,13 @@ +import { drizzle } from 'drizzle-orm/d1' +import { drizzlePersistence, schema } from '@tanstack/ai-persistence-drizzle' + +/** Create the structured stores owned by a migrated Cloudflare D1 binding. */ +export function createD1Stores(d1: D1Database) { + const persistence = drizzlePersistence(drizzle(d1, { schema })) + return { + messages: persistence.stores.messages, + runs: persistence.stores.runs, + interrupts: persistence.stores.interrupts, + metadata: persistence.stores.metadata, + } +} diff --git a/packages/ai-persistence-cloudflare/src/index.ts b/packages/ai-persistence-cloudflare/src/index.ts new file mode 100644 index 000000000..f1661ac18 --- /dev/null +++ b/packages/ai-persistence-cloudflare/src/index.ts @@ -0,0 +1,82 @@ +import { createD1Stores } from './d1' +import { createDurableObjectLockStore } from './locks' +import type { + AIPersistence, + AIPersistenceStores, + InterruptStore, + MessageStore, + MetadataStore, + RunStore, +} from '@tanstack/ai-persistence' +import type { LockStore } from '@tanstack/ai-persistence' +import type { DurableObjectLockStoreOptions } from './locks' + +export { createD1Stores } from './d1' +export { + CloudflareLockDurableObject, + createDurableObjectLockStore, +} from './locks' +export { d1Migrations } from './migrations' +export type { D1Migration } from './migrations' +export type { DurableObjectLockStoreOptions } from './locks' +export type { + DurableObjectNamespaceBinding, + DurableObjectStubBinding, + LockDurableObjectState, + LockDurableObjectStorage, +} from './bindings' + +export interface CloudflarePersistenceOptions { + d1?: D1Database + durableObjects?: DurableObjectNamespace + lockOptions?: DurableObjectLockStoreOptions +} + +interface D1Stores { + messages: MessageStore + runs: RunStore + interrupts: InterruptStore + metadata: MetadataStore +} + +interface DurableObjectStores { + locks: LockStore +} + +type BindingStores< + TOptions, + TKey extends PropertyKey, + TStores, +> = TKey extends keyof TOptions + ? undefined extends TOptions[TKey] + ? Partial + : TStores + : {} + +type Simplify = { [TKey in keyof T]: T[TKey] } + +export type CloudflarePersistenceStores = Simplify< + BindingStores & + BindingStores +> + +/** + * Compose only the stores backed by the supplied Cloudflare bindings. + * Binding keys remain exact in the return type, including optional bindings. + */ +export function cloudflarePersistence< + const TOptions extends CloudflarePersistenceOptions, +>(options: TOptions): AIPersistence> +export function cloudflarePersistence( + options: CloudflarePersistenceOptions, +): AIPersistence { + const stores: AIPersistenceStores = {} + if (options.d1) Object.assign(stores, createD1Stores(options.d1)) + if (options.durableObjects) { + stores.locks = createDurableObjectLockStore( + options.durableObjects, + options.lockOptions, + ) + } + return { stores } +} diff --git a/packages/ai-persistence-cloudflare/src/locks.ts b/packages/ai-persistence-cloudflare/src/locks.ts new file mode 100644 index 000000000..aefbf3a8c --- /dev/null +++ b/packages/ai-persistence-cloudflare/src/locks.ts @@ -0,0 +1,376 @@ +import type { LockStore } from '@tanstack/ai-persistence' +import type { + DurableObjectNamespaceBinding, + DurableObjectStubBinding, + LockDurableObjectState, +} from './bindings' + +interface LeaseRecord { + ownerId: string + expiresAt: number +} + +interface LeaseRequest { + ownerId: string + leaseDurationMs: number +} + +export interface DurableObjectLockStoreOptions { + /** Duration of each lock lease. Defaults to 30 seconds. */ + leaseDurationMs?: number + /** Renewal cadence. Defaults to one third of the lease duration. */ + renewIntervalMs?: number + /** Maximum time spent retrying a contended acquire. Defaults to 30 seconds. */ + acquireTimeoutMs?: number + /** Delay between contended acquire attempts. Defaults to 50 milliseconds. */ + retryDelayMs?: number + /** Override owner ID generation, primarily for deterministic runtimes/tests. */ + createOwnerId?: () => string +} + +interface ResolvedLockOptions { + leaseDurationMs: number + renewIntervalMs: number + acquireTimeoutMs: number + retryDelayMs: number + createOwnerId: () => string +} + +type SettledValue = + | { status: 'fulfilled'; value: T } + | { status: 'rejected'; reason: unknown } + +const leaseKey = 'lease' + +function isLeaseRecord(value: unknown): value is LeaseRecord { + return ( + value !== null && + typeof value === 'object' && + 'ownerId' in value && + typeof value.ownerId === 'string' && + 'expiresAt' in value && + typeof value.expiresAt === 'number' + ) +} + +function isLeaseRequest(value: unknown): value is LeaseRequest { + return ( + value !== null && + typeof value === 'object' && + 'ownerId' in value && + typeof value.ownerId === 'string' && + value.ownerId.length > 0 && + 'leaseDurationMs' in value && + typeof value.leaseDurationMs === 'number' && + Number.isFinite(value.leaseDurationMs) && + value.leaseDurationMs > 0 + ) +} + +function response(status: number): Response { + return new Response(null, { status }) +} + +/** + * Durable Object class backing distributed lock leases. + * + * Bind this class in Wrangler and pass the resulting namespace to + * `cloudflarePersistence({ durableObjects })`. + * + * SECURITY: this DO must ONLY be reachable through its namespace binding (via + * `createDurableObjectLockStore`). Its `fetch` handler trusts the caller's + * `ownerId` and performs no authentication, so routing public HTTP straight to + * this class exposes an unauthenticated lock-manipulation surface — anyone + * could acquire, renew, or release any lock key. Never wire it into a public + * Worker route; reach it only by `namespace.get(namespace.idFromName(key))`. + */ +export class CloudflareLockDurableObject { + private operationChain: Promise = Promise.resolve() + + constructor(private readonly state: LockDurableObjectState) {} + + fetch(request: Request): Promise { + return this.serialize(() => this.handleRequest(request)) + } + + alarm(): Promise { + return this.serialize(async () => { + const lease = await this.readLease() + if (!lease) { + await this.state.storage.deleteAlarm() + return + } + if (lease.expiresAt > Date.now()) { + await this.state.storage.setAlarm(lease.expiresAt) + return + } + await this.state.storage.delete(leaseKey) + await this.state.storage.deleteAlarm() + }) + } + + private async handleRequest(request: Request): Promise { + if (request.method !== 'POST') return response(405) + let input: unknown + try { + input = await request.json() + } catch { + return response(400) + } + if (!isLeaseRequest(input)) return response(400) + + const operation = new URL(request.url).pathname + if (operation === '/acquire') return this.acquire(input) + if (operation === '/renew') return this.renew(input) + if (operation === '/release') return this.release(input.ownerId) + return response(404) + } + + private async acquire(input: LeaseRequest): Promise { + const current = await this.readLease() + if (current && current.expiresAt > Date.now()) return response(409) + await this.writeLease(input) + return response(200) + } + + private async renew(input: LeaseRequest): Promise { + const current = await this.readLease() + if ( + !current || + current.ownerId !== input.ownerId || + current.expiresAt <= Date.now() + ) { + return response(409) + } + await this.writeLease(input) + return response(200) + } + + private async release(ownerId: string): Promise { + const current = await this.readLease() + if (!current || current.ownerId !== ownerId) return response(409) + await this.state.storage.delete(leaseKey) + await this.state.storage.deleteAlarm() + return response(200) + } + + private async readLease(): Promise { + const value = await this.state.storage.get(leaseKey) + if (value === undefined) return undefined + if (!isLeaseRecord(value)) { + throw new Error('Durable Object lock lease is invalid') + } + return value + } + + private async writeLease(input: LeaseRequest): Promise { + const lease: LeaseRecord = { + ownerId: input.ownerId, + expiresAt: Date.now() + input.leaseDurationMs, + } + await this.state.storage.put(leaseKey, lease) + await this.state.storage.setAlarm(lease.expiresAt) + } + + private serialize(operation: () => Promise): Promise { + const result = this.operationChain.then(operation, operation) + this.operationChain = result.then( + () => undefined, + () => undefined, + ) + return result + } +} + +function resolveOptions( + options: DurableObjectLockStoreOptions, +): ResolvedLockOptions { + const leaseDurationMs = options.leaseDurationMs ?? 30_000 + const renewIntervalMs = options.renewIntervalMs ?? leaseDurationMs / 3 + const acquireTimeoutMs = options.acquireTimeoutMs ?? 30_000 + const retryDelayMs = options.retryDelayMs ?? 50 + for (const [name, value] of [ + ['leaseDurationMs', leaseDurationMs], + ['renewIntervalMs', renewIntervalMs], + ['acquireTimeoutMs', acquireTimeoutMs], + ['retryDelayMs', retryDelayMs], + ] as const) { + if (!Number.isFinite(value) || value <= 0) { + throw new RangeError(`${name} must be a positive finite number`) + } + } + if (renewIntervalMs >= leaseDurationMs) { + throw new RangeError('renewIntervalMs must be less than leaseDurationMs') + } + return { + leaseDurationMs, + renewIntervalMs, + acquireTimeoutMs, + retryDelayMs, + createOwnerId: options.createOwnerId ?? (() => crypto.randomUUID()), + } +} + +function sleep(milliseconds: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal?.aborted) { + resolve() + return + } + const timeout = setTimeout(() => { + signal?.removeEventListener('abort', stopWaiting) + resolve() + }, milliseconds) + const stopWaiting = () => { + clearTimeout(timeout) + resolve() + } + signal?.addEventListener('abort', stopWaiting, { once: true }) + }) +} + +function isAborted(signal: AbortSignal): boolean { + return signal.aborted +} + +async function settle(promise: Promise): Promise> { + try { + return { status: 'fulfilled', value: await promise } + } catch (error) { + return { status: 'rejected', reason: error } + } +} + +async function lockOperation( + stub: DurableObjectStubBinding, + operation: 'acquire' | 'renew' | 'release', + ownerId: string, + leaseDurationMs: number, +): Promise { + return stub.fetch( + new Request(`https://tanstack-ai-lock.invalid/${operation}`, { + method: 'POST', + body: JSON.stringify({ ownerId, leaseDurationMs }), + headers: { 'content-type': 'application/json' }, + }), + ) +} + +function operationError(operation: string, status: number): Error { + return new Error( + `Durable Object lock ${operation} failed with status ${status}`, + ) +} + +async function acquireLease( + stub: DurableObjectStubBinding, + ownerId: string, + options: ResolvedLockOptions, +): Promise { + const deadline = Date.now() + options.acquireTimeoutMs + for (;;) { + const result = await lockOperation( + stub, + 'acquire', + ownerId, + options.leaseDurationMs, + ) + if (result.ok) return + if (result.status !== 409) throw operationError('acquire', result.status) + if (Date.now() >= deadline) { + throw new Error('Durable Object lock acquire timed out') + } + await sleep(options.retryDelayMs) + } +} + +async function renewLeaseUntilFinished( + stub: DurableObjectStubBinding, + ownerId: string, + options: ResolvedLockOptions, + signal: AbortSignal, +): Promise { + for (;;) { + if (isAborted(signal)) return + await sleep(options.renewIntervalMs, signal) + if (isAborted(signal)) return + const result = await lockOperation( + stub, + 'renew', + ownerId, + options.leaseDurationMs, + ) + if (!result.ok) throw operationError('renew', result.status) + } +} + +function throwFailures(failures: Array): void { + if (failures.length === 1) throw failures[0] + if (failures.length > 1) { + throw new AggregateError(failures, 'Durable Object lock operation failed') + } +} + +/** Create a distributed LockStore backed by one Durable Object per lock key. */ +export function createDurableObjectLockStore( + namespace: DurableObjectNamespaceBinding, + lockOptions: DurableObjectLockStoreOptions = {}, +): LockStore { + const options = resolveOptions(lockOptions) + return { + async withLock( + key: string, + fn: (signal: AbortSignal) => Promise, + ): Promise { + const ownerId = options.createOwnerId() + const stub = namespace.get(namespace.idFromName(key)) + await acquireLease(stub, ownerId, options) + + const workFinished = new AbortController() + const leaseOwned = new AbortController() + const workResultPromise = settle( + Promise.resolve().then(() => fn(leaseOwned.signal)), + ) + const renewalResultPromise = settle( + renewLeaseUntilFinished( + stub, + ownerId, + options, + workFinished.signal, + ).catch((error: unknown) => { + leaseOwned.abort(error) + throw error + }), + ) + const workResult = await workResultPromise + workFinished.abort() + const renewalResult = await renewalResultPromise + // A 409 from `release` means the lease was no longer ours (it expired, or + // another owner acquired it) by the time the critical section finished. + // We surface that as a thrown error and let `withLock` reject even though + // the work itself may have completed: a lost lease means mutual exclusion + // could have been violated mid-section, so the caller must NOT treat the + // result as if it ran under the lock. `leaseOwned` is aborted when renewal + // fails, so a well-behaved critical section will already have stopped. + const releaseResult = await settle( + lockOperation(stub, 'release', ownerId, options.leaseDurationMs).then( + (result) => { + if (!result.ok) throw operationError('release', result.status) + }, + ), + ) + + const failures: Array = [] + if (workResult.status === 'rejected') failures.push(workResult.reason) + if (renewalResult.status === 'rejected') { + failures.push(renewalResult.reason) + } + if (releaseResult.status === 'rejected') { + failures.push(releaseResult.reason) + } + throwFailures(failures) + if (workResult.status === 'rejected') throw workResult.reason + return workResult.value + }, + } +} diff --git a/packages/ai-persistence-cloudflare/src/migration-cli.ts b/packages/ai-persistence-cloudflare/src/migration-cli.ts new file mode 100644 index 000000000..ff3ecc17a --- /dev/null +++ b/packages/ai-persistence-cloudflare/src/migration-cli.ts @@ -0,0 +1,125 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { d1Migrations } from './migrations' + +export interface MigrationCliOutput { + writeStdout: (value: string) => void +} + +export class MigrationCliError extends Error { + constructor(message: string) { + super(message) + this.name = 'MigrationCliError' + } +} + +const usage = `Usage: tanstack-ai-cloudflare-migrations (--out | --stdout) [--force] + +Options: + --out Copy the ordered Cloudflare D1 migration files. + --stdout Print the ordered migration SQL. + --force Replace divergent files when copying. + --help Show this help. +` + +interface ParsedArguments { + force: boolean + help: boolean + outDirectory?: string + stdout: boolean +} + +function parseArguments(args: ReadonlyArray): ParsedArguments { + let force = false + let help = false + let outDirectory: string | undefined + let stdout = false + + for (let index = 0; index < args.length; index++) { + const argument = args[index] + if (argument === '--force') { + force = true + } else if (argument === '--help' || argument === '-h') { + help = true + } else if (argument === '--stdout') { + stdout = true + } else if (argument === '--out') { + const value = args[index + 1] + if (!value || value.startsWith('-')) { + throw new MigrationCliError('--out requires a directory.') + } + if (outDirectory !== undefined) { + throw new MigrationCliError('--out may only be provided once.') + } + outDirectory = value + index++ + } else { + throw new MigrationCliError(`Unknown argument: ${argument ?? ''}`) + } + } + + return { force, help, outDirectory, stdout } +} + +function isMissingFileError(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +async function readExistingFile(path: string): Promise { + try { + return await readFile(path, 'utf8') + } catch (error) { + if (isMissingFileError(error)) return undefined + throw error + } +} + +/** Execute the D1 migration asset CLI. Exported for deterministic CLI tests. */ +export async function runCloudflareMigrationsCli( + args: ReadonlyArray, + output: MigrationCliOutput = { + writeStdout(value) { + process.stdout.write(value) + }, + }, +): Promise { + const parsed = parseArguments(args) + if (parsed.help) { + output.writeStdout(usage) + return + } + + const outputCount = + Number(parsed.stdout) + Number(parsed.outDirectory != null) + if (outputCount !== 1) { + throw new MigrationCliError( + 'Provide exactly one output mode: --out or --stdout.', + ) + } + if (parsed.stdout) { + if (parsed.force) { + throw new MigrationCliError('--force can only be used with --out.') + } + output.writeStdout( + `${d1Migrations.map((migration) => migration.sql.trimEnd()).join('\n\n')}\n`, + ) + return + } + + const outDirectory = parsed.outDirectory + if (!outDirectory) { + throw new MigrationCliError('--out requires a directory.') + } + await mkdir(outDirectory, { recursive: true }) + for (const migration of d1Migrations) { + const destination = join(outDirectory, migration.filename) + const existing = await readExistingFile(destination) + if (existing === migration.sql) continue + if (existing !== undefined && !parsed.force) { + throw new MigrationCliError( + `Refusing to overwrite divergent migration file: ${destination}. Re-run with --force to replace it.`, + ) + } + await writeFile(destination, migration.sql, 'utf8') + } +} diff --git a/packages/ai-persistence-cloudflare/src/migrations.ts b/packages/ai-persistence-cloudflare/src/migrations.ts new file mode 100644 index 000000000..0d24c5b49 --- /dev/null +++ b/packages/ai-persistence-cloudflare/src/migrations.ts @@ -0,0 +1,25 @@ +import initialMigrationSql from './assets/0000_tanstack_ai_initial.sql?raw' + +export interface D1Migration { + id: string + filename: string + sql: string +} + +/** + * PROVENANCE: `assets/0000_tanstack_ai_initial.sql` is a byte-for-byte copy of + * the Drizzle asset + * (`@tanstack/ai-persistence-drizzle`'s `src/assets/0000_tanstack_ai_initial.sql`). + * `createD1Stores` runs the Drizzle stores against a D1 database migrated with + * this SQL, so the copy MUST stay identical to Drizzle's. The test "cloudflare + * D1 asset matches the drizzle asset" (`tests/migrations.test.ts`) fails on any + * drift. + */ +/** Ordered D1 migrations for messages, runs, interrupts, and metadata. */ +export const d1Migrations: ReadonlyArray = [ + { + id: '0000_tanstack_ai_initial', + filename: '0000_tanstack_ai_initial.sql', + sql: initialMigrationSql, + }, +] diff --git a/packages/ai-persistence-cloudflare/tests/api-types.test-d.ts b/packages/ai-persistence-cloudflare/tests/api-types.test-d.ts new file mode 100644 index 000000000..63f3c482d --- /dev/null +++ b/packages/ai-persistence-cloudflare/tests/api-types.test-d.ts @@ -0,0 +1,65 @@ +/// +import { expectTypeOf } from 'vitest' +import { composePersistence } from '@tanstack/ai-persistence' +import { + CloudflareLockDurableObject, + cloudflarePersistence, +} from '../src/index' +import type { + InterruptStore, + LockStore, + MessageStore, + MetadataStore, + RunStore, +} from '@tanstack/ai-persistence' + +declare const d1: D1Database +declare const durableObjects: DurableObjectNamespace +declare const durableObjectState: DurableObjectState + +new CloudflareLockDurableObject(durableObjectState) + +expectTypeOf(cloudflarePersistence({}).stores).toEqualTypeOf<{}>() +expectTypeOf(cloudflarePersistence({ d1 }).stores).toEqualTypeOf<{ + messages: MessageStore + runs: RunStore + interrupts: InterruptStore + metadata: MetadataStore +}>() +expectTypeOf(cloudflarePersistence({ durableObjects }).stores).toEqualTypeOf<{ + locks: LockStore +}>() +expectTypeOf( + cloudflarePersistence({ d1, durableObjects }).stores, +).toEqualTypeOf<{ + messages: MessageStore + runs: RunStore + interrupts: InterruptStore + metadata: MetadataStore + locks: LockStore +}>() + +declare const optionalD1: D1Database | undefined +expectTypeOf(cloudflarePersistence({ d1: optionalD1 }).stores).toEqualTypeOf<{ + messages?: MessageStore + runs?: RunStore + interrupts?: InterruptStore + metadata?: MetadataStore +}>() + +const d1Persistence = cloudflarePersistence({ d1 }) +declare const customInterrupts: InterruptStore +const replaced = composePersistence(d1Persistence, { + overrides: { interrupts: customInterrupts }, +}) +expectTypeOf(replaced.stores.interrupts).toEqualTypeOf() +expectTypeOf(replaced.stores.runs).toEqualTypeOf() + +const removed = composePersistence(d1Persistence, { + overrides: { interrupts: false }, +}) +expectTypeOf(removed.stores).toEqualTypeOf<{ + messages: MessageStore + runs: RunStore + metadata: MetadataStore +}>() diff --git a/packages/ai-persistence-cloudflare/tests/locks.test.ts b/packages/ai-persistence-cloudflare/tests/locks.test.ts new file mode 100644 index 000000000..c40822089 --- /dev/null +++ b/packages/ai-persistence-cloudflare/tests/locks.test.ts @@ -0,0 +1,359 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + CloudflareLockDurableObject, + createDurableObjectLockStore, +} from '../src/index' +import type { + DurableObjectNamespaceBinding, + DurableObjectStubBinding, + LockDurableObjectStorage, +} from '../src/index' + +class FakeLockStorage implements LockDurableObjectStorage { + readonly values = new Map() + alarm: number | undefined + + get(key: string): Promise { + return Promise.resolve(this.values.get(key)) + } + + put(key: string, value: unknown): Promise { + this.values.set(key, value) + return Promise.resolve() + } + + delete(key: string): Promise { + return Promise.resolve(this.values.delete(key)) + } + + setAlarm(timestamp: number): Promise { + this.alarm = timestamp + return Promise.resolve() + } + + deleteAlarm(): Promise { + this.alarm = undefined + return Promise.resolve() + } +} + +function request( + operation: 'acquire' | 'renew' | 'release', + ownerId: string, + leaseDurationMs = 100, +): Request { + return new Request(`https://lock.invalid/${operation}`, { + method: 'POST', + body: JSON.stringify({ ownerId, leaseDurationMs }), + headers: { 'content-type': 'application/json' }, + }) +} + +function deferred() { + let resolve: (value: T) => void = () => undefined + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve + }) + return { promise, resolve } +} + +function waitForAbort(signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve() + return new Promise((resolve) => { + signal.addEventListener('abort', () => resolve(), { once: true }) + }) +} + +async function ownerIdFrom(request: Request): Promise { + const body: unknown = await request.clone().json() + if ( + typeof body !== 'object' || + body === null || + !('ownerId' in body) || + typeof body.ownerId !== 'string' + ) { + return undefined + } + return body.ownerId +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('CloudflareLockDurableObject', () => { + it('acquires, renews, and releases an owner-scoped lease', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const storage = new FakeLockStorage() + const durableObject = new CloudflareLockDurableObject({ storage }) + + expect((await durableObject.fetch(request('acquire', 'one'))).status).toBe( + 200, + ) + expect((await durableObject.fetch(request('acquire', 'two'))).status).toBe( + 409, + ) + vi.setSystemTime(1_050) + expect((await durableObject.fetch(request('renew', 'one'))).status).toBe( + 200, + ) + expect(storage.alarm).toBe(1_150) + expect((await durableObject.fetch(request('release', 'two'))).status).toBe( + 409, + ) + expect((await durableObject.fetch(request('release', 'one'))).status).toBe( + 200, + ) + expect((await durableObject.fetch(request('acquire', 'two'))).status).toBe( + 200, + ) + }) + + it('expires a lease from the Durable Object alarm', async () => { + vi.useFakeTimers() + vi.setSystemTime(2_000) + const storage = new FakeLockStorage() + const durableObject = new CloudflareLockDurableObject({ storage }) + await durableObject.fetch(request('acquire', 'one')) + + vi.setSystemTime(2_101) + await durableObject.alarm() + + expect((await durableObject.fetch(request('acquire', 'two'))).status).toBe( + 200, + ) + }) +}) + +describe('Durable Object LockStore', () => { + it('renews a long-running critical section and awaits release', async () => { + vi.useFakeTimers() + vi.setSystemTime(3_000) + const storage = new FakeLockStorage() + const server = new CloudflareLockDurableObject({ storage }) + const operations: Array = [] + const stub: DurableObjectStubBinding = { + fetch(input) { + const requestInput = + input instanceof Request ? input : new Request(input) + operations.push(new URL(requestInput.url).pathname) + return server.fetch(requestInput) + }, + } + const namespace: DurableObjectNamespaceBinding = { + idFromName: (name) => name, + get: () => stub, + } + const store = createDurableObjectLockStore(namespace, { + leaseDurationMs: 100, + renewIntervalMs: 40, + retryDelayMs: 5, + acquireTimeoutMs: 100, + createOwnerId: () => 'owner', + }) + const work = deferred() + const result = store.withLock('workspace', () => work.promise) + + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(41) + expect(operations).toContain('/renew') + work.resolve('done') + await expect(result).resolves.toBe('done') + expect(operations.at(-1)).toBe('/release') + }) + + it('surfaces renewal failure after user work settles and still releases', async () => { + vi.useFakeTimers() + const operations: Array = [] + const stub: DurableObjectStubBinding = { + fetch(input) { + const requestInput = + input instanceof Request ? input : new Request(input) + const operation = new URL(requestInput.url).pathname + operations.push(operation) + return Promise.resolve( + new Response(null, { status: operation === '/renew' ? 503 : 200 }), + ) + }, + } + const namespace: DurableObjectNamespaceBinding = { + idFromName: (name) => name, + get: () => stub, + } + const store = createDurableObjectLockStore(namespace, { + leaseDurationMs: 100, + renewIntervalMs: 40, + createOwnerId: () => 'owner', + }) + const work = deferred() + const result = store.withLock('workspace', () => work.promise) + + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(41) + work.resolve('done') + + await expect(result).rejects.toThrow(/renew.*503/i) + expect(operations.at(-1)).toBe('/release') + }) + + it('aborts a lost lease before another owner enters after expiry', async () => { + vi.useFakeTimers() + vi.setSystemTime(4_000) + const storage = new FakeLockStorage() + const server = new CloudflareLockDurableObject({ storage }) + const stub: DurableObjectStubBinding = { + async fetch(input) { + const requestInput = + input instanceof Request ? input : new Request(input) + const operation = new URL(requestInput.url).pathname + const ownerId = await ownerIdFrom(requestInput) + if ( + ownerId === 'owner-1' && + (operation === '/renew' || operation === '/release') + ) { + return new Response(null, { status: 503 }) + } + return server.fetch(requestInput) + }, + } + const namespace: DurableObjectNamespaceBinding = { + idFromName: (name) => name, + get: () => stub, + } + const firstStore = createDurableObjectLockStore(namespace, { + leaseDurationMs: 100, + renewIntervalMs: 40, + retryDelayMs: 5, + acquireTimeoutMs: 200, + createOwnerId: () => 'owner-1', + }) + const secondStore = createDurableObjectLockStore(namespace, { + leaseDurationMs: 100, + renewIntervalMs: 40, + retryDelayMs: 5, + acquireTimeoutMs: 200, + createOwnerId: () => 'owner-2', + }) + const legacyStop = deferred() + let active = 0 + let maximumActive = 0 + let firstAborted = false + + const first = firstStore.withLock('workspace', async (signal) => { + active += 1 + maximumActive = Math.max(maximumActive, active) + if (signal instanceof AbortSignal) { + await waitForAbort(signal) + firstAborted = signal.aborted + } else { + await legacyStop.promise + } + active -= 1 + }) + const firstError = first.then( + () => undefined, + (error: unknown) => error, + ) + await vi.advanceTimersByTimeAsync(0) + const second = secondStore.withLock('workspace', async () => { + active += 1 + maximumActive = Math.max(maximumActive, active) + active -= 1 + return 'second' + }) + + await vi.advanceTimersByTimeAsync(110) + legacyStop.resolve() + await expect(second).resolves.toBe('second') + await expect(firstError).resolves.toBeInstanceOf(AggregateError) + + expect(firstAborted).toBe(true) + expect(maximumActive).toBe(1) + }) + + it('surfaces release failure', async () => { + const stub: DurableObjectStubBinding = { + fetch(input) { + const requestInput = + input instanceof Request ? input : new Request(input) + const operation = new URL(requestInput.url).pathname + return Promise.resolve( + new Response(null, { status: operation === '/release' ? 503 : 200 }), + ) + }, + } + const namespace: DurableObjectNamespaceBinding = { + idFromName: (name) => name, + get: () => stub, + } + const store = createDurableObjectLockStore(namespace, { + createOwnerId: () => 'owner', + }) + + await expect(store.withLock('workspace', async () => 42)).rejects.toThrow( + /release.*503/i, + ) + }) + + it('preserves a critical section rejection with an undefined reason', async () => { + const namespace: DurableObjectNamespaceBinding = { + idFromName: (name) => name, + get: () => ({ fetch: () => Promise.resolve(new Response()) }), + } + const store = createDurableObjectLockStore(namespace) + + await expect( + store.withLock('workspace', () => Promise.reject(undefined)), + ).rejects.toBeUndefined() + }) + + it('aggregates critical section and release failures', async () => { + const workError = new Error('work failed') + const stub: DurableObjectStubBinding = { + fetch(input) { + const requestInput = + input instanceof Request ? input : new Request(input) + const operation = new URL(requestInput.url).pathname + return Promise.resolve( + new Response(null, { status: operation === '/release' ? 503 : 200 }), + ) + }, + } + const namespace: DurableObjectNamespaceBinding = { + idFromName: (name) => name, + get: () => stub, + } + const store = createDurableObjectLockStore(namespace) + + const error = await store + .withLock('workspace', () => Promise.reject(workError)) + .then( + () => undefined, + (reason: unknown) => reason, + ) + + expect(error).toBeInstanceOf(AggregateError) + expect(error).toMatchObject({ + errors: [ + workError, + expect.objectContaining({ + message: expect.stringMatching(/release.*503/i), + }), + ], + }) + }) + + it('rejects invalid lease timing before acquiring', () => { + const namespace: DurableObjectNamespaceBinding = { + idFromName: (name) => name, + get: () => ({ fetch: () => Promise.resolve(new Response()) }), + } + expect(() => + createDurableObjectLockStore(namespace, { + leaseDurationMs: 100, + renewIntervalMs: 100, + }), + ).toThrow(/renewIntervalMs.*leaseDurationMs/) + }) +}) diff --git a/packages/ai-persistence-cloudflare/tests/migration-cli.test.ts b/packages/ai-persistence-cloudflare/tests/migration-cli.test.ts new file mode 100644 index 000000000..5e5fd529b --- /dev/null +++ b/packages/ai-persistence-cloudflare/tests/migration-cli.test.ts @@ -0,0 +1,65 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { d1Migrations } from '../src/migrations' +import { runCloudflareMigrationsCli } from '../src/migration-cli' + +const temporaryDirectories: Array = [] + +async function createTemporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'tanstack-ai-cloudflare-cli-')) + temporaryDirectories.push(directory) + return directory +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ) +}) + +describe('Cloudflare D1 migrations CLI', () => { + it('prints canonical migration SQL to stdout', async () => { + let output = '' + await runCloudflareMigrationsCli(['--stdout'], { + writeStdout(value) { + output += value + }, + }) + + expect(output).toBe(`${d1Migrations[0]?.sql.trimEnd()}\n`) + }) + + it('copies migrations without overwriting divergent files by default', async () => { + const directory = await createTemporaryDirectory() + await runCloudflareMigrationsCli(['--out', directory]) + + const migration = d1Migrations[0] + expect(migration).toBeDefined() + if (!migration) return + const destination = join(directory, migration.filename) + expect(await readFile(destination, 'utf8')).toBe(migration.sql) + + await writeFile(destination, 'user-owned contents', 'utf8') + await expect( + runCloudflareMigrationsCli(['--out', directory]), + ).rejects.toThrow(/refusing to overwrite/i) + expect(await readFile(destination, 'utf8')).toBe('user-owned contents') + + await runCloudflareMigrationsCli(['--out', directory, '--force']) + expect(await readFile(destination, 'utf8')).toBe(migration.sql) + }) + + it('fails on ambiguous or incomplete output options', async () => { + const directory = await createTemporaryDirectory() + await expect(runCloudflareMigrationsCli([])).rejects.toThrow( + /--out.*--stdout/i, + ) + await expect( + runCloudflareMigrationsCli(['--stdout', '--out', directory]), + ).rejects.toThrow(/exactly one/i) + }) +}) diff --git a/packages/ai-persistence-cloudflare/tests/migrations.test.ts b/packages/ai-persistence-cloudflare/tests/migrations.test.ts new file mode 100644 index 000000000..f2ddeeed6 --- /dev/null +++ b/packages/ai-persistence-cloudflare/tests/migrations.test.ts @@ -0,0 +1,53 @@ +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { d1Migrations } from '../src/index' + +async function readAsset(url: string): Promise { + return readFile(fileURLToPath(new URL(url, import.meta.url)), 'utf8') +} + +describe('D1 migrations', () => { + it('exports the canonical structured-state migration', () => { + expect(d1Migrations).toHaveLength(1) + expect(d1Migrations[0]).toMatchObject({ + id: '0000_tanstack_ai_initial', + filename: '0000_tanstack_ai_initial.sql', + }) + const sql = d1Migrations[0]?.sql ?? '' + expect(sql).toMatch(/CREATE TABLE [`"]messages[`"]/) + expect(sql).toMatch(/CREATE TABLE [`"]runs[`"]/) + expect(sql).toMatch(/CREATE TABLE [`"]interrupts[`"]/) + expect(sql).toMatch(/CREATE TABLE [`"]metadata[`"]/) + }) + + it('keeps the published migration equal to the embedded asset', async () => { + const embedded = await readFile( + fileURLToPath( + new URL('../src/assets/0000_tanstack_ai_initial.sql', import.meta.url), + ), + 'utf8', + ) + const published = await readFile( + fileURLToPath( + new URL('../migrations/0000_tanstack_ai_initial.sql', import.meta.url), + ), + 'utf8', + ) + expect(published).toBe(embedded) + expect(d1Migrations[0]?.sql).toBe(embedded) + }) + + // `createD1Stores` runs the Drizzle stores against a D1 database migrated + // with this copy of the Drizzle migration, so it must stay byte-for-byte + // identical to the Drizzle asset. Drift breaks the stores at runtime. + it('cloudflare D1 asset matches the drizzle asset', async () => { + const cloudflareSql = await readAsset( + '../src/assets/0000_tanstack_ai_initial.sql', + ) + const drizzleSql = await readAsset( + '../../ai-persistence-drizzle/src/assets/0000_tanstack_ai_initial.sql', + ) + expect(cloudflareSql).toBe(drizzleSql) + }) +}) diff --git a/packages/ai-persistence-cloudflare/tests/package-contract.test.ts b/packages/ai-persistence-cloudflare/tests/package-contract.test.ts new file mode 100644 index 000000000..8c8039860 --- /dev/null +++ b/packages/ai-persistence-cloudflare/tests/package-contract.test.ts @@ -0,0 +1,46 @@ +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import packageJson from '../package.json' + +describe('Cloudflare package contract', () => { + it('starts at 0.0.1 and publishes the root, CLI, and D1 assets', () => { + expect(packageJson.exports).toEqual({ + '.': { + types: './dist/esm/index.d.ts', + import: './dist/esm/index.js', + }, + }) + expect(packageJson.bin).toEqual({ + 'tanstack-ai-cloudflare-migrations': + './bin/tanstack-ai-cloudflare-migrations.mjs', + }) + expect(packageJson.files).toEqual( + expect.arrayContaining(['bin', 'dist', 'migrations', 'src']), + ) + }) + + it('keeps Node built-ins, Buffer, and the CLI out of the root graph', async () => { + const rootFiles = [ + 'bindings.ts', + 'd1.ts', + 'index.ts', + 'locks.ts', + 'migrations.ts', + ] + for (const filename of rootFiles) { + const contents = await readFile( + fileURLToPath(new URL(`../src/${filename}`, import.meta.url)), + 'utf8', + ) + expect(contents, filename).not.toMatch(/from ['"]node:/) + expect(contents, filename).not.toMatch(/\bBuffer\b/) + } + const root = await readFile( + fileURLToPath(new URL('../src/index.ts', import.meta.url)), + 'utf8', + ) + expect(root).not.toMatch(/migration-cli/) + expect(root).not.toMatch(/\.\/cli/) + }) +}) diff --git a/packages/ai-persistence-cloudflare/tests/runtime.conformance.test.ts b/packages/ai-persistence-cloudflare/tests/runtime.conformance.test.ts new file mode 100644 index 000000000..fcfdee664 --- /dev/null +++ b/packages/ai-persistence-cloudflare/tests/runtime.conformance.test.ts @@ -0,0 +1,77 @@ +/// +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { Miniflare } from 'miniflare' +import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' +import { cloudflarePersistence, d1Migrations } from '../src/index' +import { composePersistence } from '@tanstack/ai-persistence' +import type { AIPersistence, InterruptStore } from '@tanstack/ai-persistence' + +interface RuntimeBindings { + AI_DB: D1Database +} + +describe('Cloudflare persistence on Miniflare bindings', () => { + let miniflare: Miniflare + let persistence: AIPersistence + + beforeAll(async () => { + miniflare = new Miniflare({ + compatibilityDate: '2026-06-24', + d1Databases: ['AI_DB'], + modules: true, + script: 'export default { fetch() { return new Response("ok") } }', + }) + const bindings = await miniflare.getBindings() + for (const migration of d1Migrations) { + const statements = migration.sql + .split('--> statement-breakpoint') + .map((statement) => statement.trim()) + .filter((statement) => statement.length > 0) + await bindings.AI_DB.batch( + statements.map((statement) => bindings.AI_DB.prepare(statement)), + ) + } + persistence = cloudflarePersistence({ + d1: bindings.AI_DB, + }) + }) + + afterAll(async () => { + await miniflare.dispose() + }) + + runPersistenceConformance('cloudflare-d1', () => persistence, { + // This composition supplies only a D1 binding (no Durable Object), so + // there is no lock store. + skip: ['locks'], + }) + + it('composes a custom interrupt store while retaining D1 runs', () => { + const customInterrupts: InterruptStore = { + create: () => Promise.resolve(), + resolve: () => Promise.resolve(), + cancel: () => Promise.resolve(), + get: () => Promise.resolve(null), + list: () => Promise.resolve([]), + listPending: () => Promise.resolve([]), + listByRun: () => Promise.resolve([]), + listPendingByRun: () => Promise.resolve([]), + } + const composed = composePersistence(persistence, { + overrides: { interrupts: customInterrupts }, + }) + + expect(composed.stores.interrupts).toBe(customInterrupts) + expect(composed.stores.runs).toBe(persistence.stores.runs) + }) + + it('removes only stores explicitly disabled by an override', () => { + const composed = composePersistence(persistence, { + overrides: { interrupts: false }, + }) + + expect('interrupts' in composed.stores).toBe(false) + expect(composed.stores.runs).toBe(persistence.stores.runs) + expect(composed.stores.messages).toBe(persistence.stores.messages) + }) +}) diff --git a/packages/ai-persistence-cloudflare/tsconfig.json b/packages/ai-persistence-cloudflare/tsconfig.json new file mode 100644 index 000000000..1ca53bbf0 --- /dev/null +++ b/packages/ai-persistence-cloudflare/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "types": ["node", "@cloudflare/workers-types"] + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ai-persistence-cloudflare/vite.config.ts b/packages/ai-persistence-cloudflare/vite.config.ts new file mode 100644 index 000000000..bba59f080 --- /dev/null +++ b/packages/ai-persistence-cloudflare/vite.config.ts @@ -0,0 +1,40 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + '**/types.ts', + ], + include: ['src/**/*.ts'], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts', './src/cli.ts'], + srcDir: './src', + cjs: false, + beforeWriteDeclarationFile(filePath, content) { + if (!/[\\/]index\.d\.ts$/.test(filePath)) return content + return `/// \n${content}` + }, + }), +) diff --git a/packages/ai-persistence-drizzle/bin/tanstack-ai-drizzle-migrations.mjs b/packages/ai-persistence-drizzle/bin/tanstack-ai-drizzle-migrations.mjs new file mode 100755 index 000000000..0a4de6588 --- /dev/null +++ b/packages/ai-persistence-drizzle/bin/tanstack-ai-drizzle-migrations.mjs @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import '../dist/esm/cli.js' diff --git a/packages/ai-persistence-drizzle/bin/tanstack-ai-drizzle-schema.mjs b/packages/ai-persistence-drizzle/bin/tanstack-ai-drizzle-schema.mjs new file mode 100755 index 000000000..c33270c7f --- /dev/null +++ b/packages/ai-persistence-drizzle/bin/tanstack-ai-drizzle-schema.mjs @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import '../dist/esm/schema-cli-main.js' diff --git a/packages/ai-persistence-drizzle/drizzle.config.ts b/packages/ai-persistence-drizzle/drizzle.config.ts new file mode 100644 index 000000000..a3602a3d5 --- /dev/null +++ b/packages/ai-persistence-drizzle/drizzle.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'drizzle-kit' + +/** + * drizzle-kit config for the SQLite schema. Migrations are generated from + * `src/schema.ts` into `drizzle/`, shipped with the package, exposed through + * `sqliteMigrations`, and applied by the Node-only `sqlitePersistence` factory. + * + * Regenerate with `pnpm db:generate` after any schema change. `sqliteMigrations` + * and the D1 sibling load the DUPLICATE at `src/assets/0000_tanstack_ai_initial.sql`, + * which `db:generate` does NOT update — copy the regenerated + * `drizzle/0000_tanstack_ai_initial.sql` over it (the package-contract test + * "keeps the shipped drizzle-kit migration equal to the embedded asset" guards + * the two against drift). + */ +export default defineConfig({ + dialect: 'sqlite', + schema: './src/schema.ts', + out: './drizzle', +}) diff --git a/packages/ai-persistence-drizzle/drizzle/0000_tanstack_ai_initial.sql b/packages/ai-persistence-drizzle/drizzle/0000_tanstack_ai_initial.sql new file mode 100644 index 000000000..3e5570ec1 --- /dev/null +++ b/packages/ai-persistence-drizzle/drizzle/0000_tanstack_ai_initial.sql @@ -0,0 +1,32 @@ +CREATE TABLE `interrupts` ( + `interrupt_id` text PRIMARY KEY NOT NULL, + `run_id` text NOT NULL, + `thread_id` text NOT NULL, + `status` text NOT NULL, + `requested_at` integer NOT NULL, + `resolved_at` integer, + `payload_json` text NOT NULL, + `response_json` text +); +--> statement-breakpoint +CREATE TABLE `messages` ( + `thread_id` text PRIMARY KEY NOT NULL, + `messages_json` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `metadata` ( + `scope` text NOT NULL, + `key` text NOT NULL, + `value_json` text NOT NULL, + PRIMARY KEY(`scope`, `key`) +); +--> statement-breakpoint +CREATE TABLE `runs` ( + `run_id` text PRIMARY KEY NOT NULL, + `thread_id` text NOT NULL, + `status` text NOT NULL, + `started_at` integer NOT NULL, + `finished_at` integer, + `error` text, + `usage_json` text +); diff --git a/packages/ai-persistence-drizzle/drizzle/meta/0000_snapshot.json b/packages/ai-persistence-drizzle/drizzle/meta/0000_snapshot.json new file mode 100644 index 000000000..2f24bec81 --- /dev/null +++ b/packages/ai-persistence-drizzle/drizzle/meta/0000_snapshot.json @@ -0,0 +1,203 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "7c2d61ca-85d3-486d-be8c-ff358edba179", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "interrupts": { + "name": "interrupts", + "columns": { + "interrupt_id": { + "name": "interrupt_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_at": { + "name": "requested_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response_json": { + "name": "response_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "messages_json": { + "name": "messages_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "metadata": { + "name": "metadata", + "columns": { + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value_json": { + "name": "value_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "metadata_scope_key_pk": { + "columns": ["scope", "key"], + "name": "metadata_scope_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "runs": { + "name": "runs", + "columns": { + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage_json": { + "name": "usage_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/ai-persistence-drizzle/drizzle/meta/_journal.json b/packages/ai-persistence-drizzle/drizzle/meta/_journal.json new file mode 100644 index 000000000..29a26ea4b --- /dev/null +++ b/packages/ai-persistence-drizzle/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1784347380743, + "tag": "0000_tanstack_ai_initial", + "breakpoints": true + } + ] +} diff --git a/packages/ai-persistence-drizzle/package.json b/packages/ai-persistence-drizzle/package.json new file mode 100644 index 000000000..cf29897bf --- /dev/null +++ b/packages/ai-persistence-drizzle/package.json @@ -0,0 +1,66 @@ +{ + "name": "@tanstack/ai-persistence-drizzle", + "version": "0.0.0", + "description": "SQLite-family Drizzle persistence for TanStack AI, with an edge-safe bring-your-own database adapter, a Node SQLite factory, and bundled migrations.", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-persistence-drizzle" + }, + "keywords": [ + "ai", + "tanstack", + "persistence", + "drizzle", + "sqlite" + ], + "type": "module", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + }, + "./sqlite": { + "types": "./dist/esm/sqlite.d.ts", + "import": "./dist/esm/sqlite.js" + } + }, + "bin": { + "tanstack-ai-drizzle-migrations": "./bin/tanstack-ai-drizzle-migrations.mjs", + "tanstack-ai-drizzle-schema": "./bin/tanstack-ai-drizzle-schema.mjs" + }, + "files": [ + "bin", + "dist", + "src", + "drizzle", + "drizzle.config.ts" + ], + "scripts": { + "build": "vite build", + "clean": "premove ./build ./dist", + "db:generate": "drizzle-kit generate", + "lint:fix": "oxlint src --type-aware --fix", + "test:build": "publint --strict", + "test:lib": "vitest", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc", + "test:oxlint": "oxlint src --type-aware" + }, + "peerDependencies": { + "@tanstack/ai": "workspace:^", + "@tanstack/ai-persistence": "workspace:^", + "drizzle-orm": ">=0.44.0" + }, + "devDependencies": { + "@tanstack/ai": "workspace:*", + "@tanstack/ai-persistence": "workspace:*", + "@vitest/coverage-v8": "4.0.14", + "drizzle-kit": "^0.31.0", + "drizzle-orm": "^0.45.0" + } +} diff --git a/packages/ai-persistence-drizzle/src/assets.d.ts b/packages/ai-persistence-drizzle/src/assets.d.ts new file mode 100644 index 000000000..b02bb85ed --- /dev/null +++ b/packages/ai-persistence-drizzle/src/assets.d.ts @@ -0,0 +1,9 @@ +declare module '*.sql?raw' { + const contents: string + export default contents +} + +declare module '*.ts?raw' { + const contents: string + export default contents +} diff --git a/packages/ai-persistence-drizzle/src/assets/0000_tanstack_ai_initial.sql b/packages/ai-persistence-drizzle/src/assets/0000_tanstack_ai_initial.sql new file mode 100644 index 000000000..3e5570ec1 --- /dev/null +++ b/packages/ai-persistence-drizzle/src/assets/0000_tanstack_ai_initial.sql @@ -0,0 +1,32 @@ +CREATE TABLE `interrupts` ( + `interrupt_id` text PRIMARY KEY NOT NULL, + `run_id` text NOT NULL, + `thread_id` text NOT NULL, + `status` text NOT NULL, + `requested_at` integer NOT NULL, + `resolved_at` integer, + `payload_json` text NOT NULL, + `response_json` text +); +--> statement-breakpoint +CREATE TABLE `messages` ( + `thread_id` text PRIMARY KEY NOT NULL, + `messages_json` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `metadata` ( + `scope` text NOT NULL, + `key` text NOT NULL, + `value_json` text NOT NULL, + PRIMARY KEY(`scope`, `key`) +); +--> statement-breakpoint +CREATE TABLE `runs` ( + `run_id` text PRIMARY KEY NOT NULL, + `thread_id` text NOT NULL, + `status` text NOT NULL, + `started_at` integer NOT NULL, + `finished_at` integer, + `error` text, + `usage_json` text +); diff --git a/packages/ai-persistence-drizzle/src/assets/tanstack-ai-schema.ts b/packages/ai-persistence-drizzle/src/assets/tanstack-ai-schema.ts new file mode 100644 index 000000000..86cb944e1 --- /dev/null +++ b/packages/ai-persistence-drizzle/src/assets/tanstack-ai-schema.ts @@ -0,0 +1,73 @@ +/** + * TanStack AI persistence schema — emitted by `tanstack-ai-drizzle-schema`. + * + * This file is yours. Add it to your drizzle-kit `schema` paths so your own + * migration journal owns the DDL, then pass it back to the runtime: + * + * ```ts + * import { drizzlePersistence } from '@tanstack/ai-persistence-drizzle' + * import { schema } from './tanstack-ai-schema' + * + * const persistence = drizzlePersistence(db, { schema }) + * ``` + * + * You may rename tables and columns (or drop the explicit column names below + * and rely on your drizzle `casing` configuration) and add extra app-owned + * columns — keep added columns nullable or defaulted so the runtime's inserts + * succeed, and keep the columns below with these data shapes. + */ +import { integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import type { InterruptRecord, RunStatus } from '@tanstack/ai-persistence' +import type { ModelMessage, TokenUsage } from '@tanstack/ai' + +/** Thread message history (`MessageStore`). */ +export const messages = sqliteTable('messages', { + threadId: text('thread_id').primaryKey(), + messagesJson: text('messages_json', { mode: 'json' }) + .$type>() + .notNull(), +}) + +/** Run lifecycle records (`RunStore`). */ +export const runs = sqliteTable('runs', { + runId: text('run_id').primaryKey(), + threadId: text('thread_id').notNull(), + status: text('status').$type().notNull(), + startedAt: integer('started_at').notNull(), + finishedAt: integer('finished_at'), + error: text('error'), + usageJson: text('usage_json', { mode: 'json' }).$type(), +}) + +/** Interrupt / approval records (`InterruptStore`). */ +export const interrupts = sqliteTable('interrupts', { + interruptId: text('interrupt_id').primaryKey(), + runId: text('run_id').notNull(), + threadId: text('thread_id').notNull(), + status: text('status').$type().notNull(), + requestedAt: integer('requested_at').notNull(), + resolvedAt: integer('resolved_at'), + payloadJson: text('payload_json', { mode: 'json' }) + .$type>() + .notNull(), + responseJson: text('response_json', { mode: 'json' }).$type(), +}) + +/** Scoped key/value metadata (`MetadataStore`). */ +export const metadata = sqliteTable( + 'metadata', + { + scope: text('scope').notNull(), + key: text('key').notNull(), + valueJson: text('value_json', { mode: 'json' }).$type().notNull(), + }, + (table) => [primaryKey({ columns: [table.scope, table.key] })], +) + +/** The full state schema, for `drizzlePersistence(db, { schema })` and drizzle-kit. */ +export const schema = { + messages, + runs, + interrupts, + metadata, +} diff --git a/packages/ai-persistence-drizzle/src/cli.ts b/packages/ai-persistence-drizzle/src/cli.ts new file mode 100644 index 000000000..6b9378a08 --- /dev/null +++ b/packages/ai-persistence-drizzle/src/cli.ts @@ -0,0 +1,10 @@ +#!/usr/bin/env node +import { runDrizzleMigrationsCli } from './migration-cli' + +try { + await runDrizzleMigrationsCli(process.argv.slice(2)) +} catch (error) { + const message = error instanceof Error ? error.message : String(error) + process.stderr.write(`${message}\n`) + process.exitCode = 1 +} diff --git a/packages/ai-persistence-drizzle/src/index.ts b/packages/ai-persistence-drizzle/src/index.ts new file mode 100644 index 000000000..01b4eca9c --- /dev/null +++ b/packages/ai-persistence-drizzle/src/index.ts @@ -0,0 +1,77 @@ +/** + * Drizzle-backed SQLite-family persistence for TanStack AI. + * + * This root entry is safe to import in edge runtimes. Pass an already-created + * and migrated SQLite-compatible Drizzle database, including Cloudflare D1, to + * {@link drizzlePersistence}. Node's built-in SQLite convenience factory lives + * at `@tanstack/ai-persistence-drizzle/sqlite`. + */ +import { schema } from './schema' +import { assertTanstackAiSchema } from './schema-contract' +import { + createInterruptStore, + createMessageStore, + createMetadataStore, + createRunStore, +} from './stores' +import type { TanstackAiSqliteSchema } from './schema-contract' +import type { DrizzleSqliteDb, TanstackAiTables } from './stores' + +export { schema } from './schema' +export { sqliteMigrations } from './migrations' +export { drizzleSchemaFilename, drizzleSchemaSource } from './schema-source' +export { DrizzleSchemaError } from './schema-contract' +export type { TanstackAiSqliteSchema } from './schema-contract' +export type { SqliteMigration } from './migrations' +export type { DrizzleSqliteDb } from './stores' +// Deprecated aliases kept for backward compatibility (both are SQLite-only). +export type { TanstackAiSchema } from './schema-contract' +export type { DrizzleDb } from './stores' + +export interface DrizzlePersistenceOptions { + /** + * Operate over your project's own copy of the TanStack AI schema instead of + * the bundled one — emit it with `tanstack-ai-drizzle-schema`, add it to + * your drizzle-kit schema paths, and pass it here. Table and column database + * names are yours to change (drizzle `casing` transforms included), and + * tables may carry extra app-owned columns as long as they are nullable or + * defaulted. Defaults to the bundled `schema` export. + */ + schema?: TanstackAiSqliteSchema +} + +/** + * Wire TanStack AI persistence stores over a migrated Drizzle SQLite database. + * + * No `locks` store is returned: this backend has no distributed lock primitive, + * and bundling an `InMemoryLockStore` would silently hand multi-instance + * deployments a lock that does not lock across instances. Consumers that need a + * lock (e.g. `withSandbox`) transparently fall back to an in-process + * `InMemoryLockStore`; for cross-instance locking use a distributed backend such + * as the Cloudflare Durable Object lock (`@tanstack/ai-persistence-cloudflare`). + */ +export function drizzlePersistence( + db: DrizzleSqliteDb, + options?: DrizzlePersistenceOptions, +) { + const tables = resolveTables(options?.schema) + return { + stores: { + messages: createMessageStore(db, tables), + runs: createRunStore(db, tables), + interrupts: createInterruptStore(db, tables), + metadata: createMetadataStore(db, tables), + }, + } +} + +function resolveTables(input?: TanstackAiSqliteSchema): TanstackAiTables { + if (!input) return schema + assertTanstackAiSchema(input) + // Safe widening: the stores only depend on the column *data* shapes, which + // `TanstackAiSqliteSchema` pins at the call site, and on the runtime table/column + // objects, which carry their own database names into the generated SQL. The + // concrete `TanstackAiTables` name literals are phantom types the store code + // never relies on. + return input as TanstackAiTables +} diff --git a/packages/ai-persistence-drizzle/src/migration-cli.ts b/packages/ai-persistence-drizzle/src/migration-cli.ts new file mode 100644 index 000000000..c55b2b6cb --- /dev/null +++ b/packages/ai-persistence-drizzle/src/migration-cli.ts @@ -0,0 +1,125 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { sqliteMigrations } from './migrations' + +export interface MigrationCliOutput { + writeStdout: (value: string) => void +} + +export class MigrationCliError extends Error { + constructor(message: string) { + super(message) + this.name = 'MigrationCliError' + } +} + +const usage = `Usage: tanstack-ai-drizzle-migrations (--out | --stdout) [--force] + +Options: + --out Copy the ordered SQLite migration files. + --stdout Print the ordered migration SQL. + --force Replace divergent files when copying. + --help Show this help. +` + +interface ParsedArguments { + force: boolean + help: boolean + outDirectory?: string + stdout: boolean +} + +function parseArguments(args: ReadonlyArray): ParsedArguments { + let force = false + let help = false + let outDirectory: string | undefined + let stdout = false + + for (let index = 0; index < args.length; index++) { + const argument = args[index] + if (argument === '--force') { + force = true + } else if (argument === '--help' || argument === '-h') { + help = true + } else if (argument === '--stdout') { + stdout = true + } else if (argument === '--out') { + const value = args[index + 1] + if (!value || value.startsWith('-')) { + throw new MigrationCliError('--out requires a directory.') + } + if (outDirectory !== undefined) { + throw new MigrationCliError('--out may only be provided once.') + } + outDirectory = value + index++ + } else { + throw new MigrationCliError(`Unknown argument: ${argument ?? ''}`) + } + } + + return { force, help, outDirectory, stdout } +} + +function isMissingFileError(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +async function readExistingFile(path: string): Promise { + try { + return await readFile(path, 'utf8') + } catch (error) { + if (isMissingFileError(error)) return undefined + throw error + } +} + +/** Execute the migration asset CLI. Exported for deterministic CLI tests. */ +export async function runDrizzleMigrationsCli( + args: ReadonlyArray, + output: MigrationCliOutput = { + writeStdout(value) { + process.stdout.write(value) + }, + }, +): Promise { + const parsed = parseArguments(args) + if (parsed.help) { + output.writeStdout(usage) + return + } + + const outputCount = + Number(parsed.stdout) + Number(parsed.outDirectory != null) + if (outputCount !== 1) { + throw new MigrationCliError( + 'Provide exactly one output mode: --out or --stdout.', + ) + } + if (parsed.stdout) { + if (parsed.force) { + throw new MigrationCliError('--force can only be used with --out.') + } + output.writeStdout( + `${sqliteMigrations.map((migration) => migration.sql.trimEnd()).join('\n\n')}\n`, + ) + return + } + + const outDirectory = parsed.outDirectory + if (!outDirectory) { + throw new MigrationCliError('--out requires a directory.') + } + await mkdir(outDirectory, { recursive: true }) + for (const migration of sqliteMigrations) { + const destination = join(outDirectory, migration.filename) + const existing = await readExistingFile(destination) + if (existing === migration.sql) continue + if (existing !== undefined && !parsed.force) { + throw new MigrationCliError( + `Refusing to overwrite divergent migration file: ${destination}. Re-run with --force to replace it.`, + ) + } + await writeFile(destination, migration.sql, 'utf8') + } +} diff --git a/packages/ai-persistence-drizzle/src/migrations.ts b/packages/ai-persistence-drizzle/src/migrations.ts new file mode 100644 index 000000000..889f6426f --- /dev/null +++ b/packages/ai-persistence-drizzle/src/migrations.ts @@ -0,0 +1,20 @@ +import initialMigrationSql from './assets/0000_tanstack_ai_initial.sql?raw' + +/** A canonical SQLite migration bundled with the adapter. */ +export interface SqliteMigration { + /** Stable identifier recorded in the TanStack AI migration table. */ + id: string + /** Filename used by the migration copy CLI. */ + filename: string + /** SQL applied atomically with its migration bookkeeping row. */ + sql: string +} + +/** Ordered canonical migrations for the TanStack AI SQLite schema. */ +export const sqliteMigrations: ReadonlyArray = [ + { + id: '0000_tanstack_ai_initial', + filename: '0000_tanstack_ai_initial.sql', + sql: initialMigrationSql, + }, +] diff --git a/packages/ai-persistence-drizzle/src/schema-cli-main.ts b/packages/ai-persistence-drizzle/src/schema-cli-main.ts new file mode 100644 index 000000000..64c7f6439 --- /dev/null +++ b/packages/ai-persistence-drizzle/src/schema-cli-main.ts @@ -0,0 +1,10 @@ +#!/usr/bin/env node +import { runDrizzleSchemaCli } from './schema-cli' + +try { + await runDrizzleSchemaCli(process.argv.slice(2)) +} catch (error) { + const message = error instanceof Error ? error.message : String(error) + process.stderr.write(`${message}\n`) + process.exitCode = 1 +} diff --git a/packages/ai-persistence-drizzle/src/schema-cli.ts b/packages/ai-persistence-drizzle/src/schema-cli.ts new file mode 100644 index 000000000..9d9de0930 --- /dev/null +++ b/packages/ai-persistence-drizzle/src/schema-cli.ts @@ -0,0 +1,124 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { drizzleSchemaFilename, drizzleSchemaSource } from './schema-source' + +export interface SchemaCliOutput { + writeStdout: (value: string) => void +} + +export class SchemaCliError extends Error { + constructor(message: string) { + super(message) + this.name = 'SchemaCliError' + } +} + +const usage = `Usage: tanstack-ai-drizzle-schema (--out | --stdout) [--force] + +Emits the TanStack AI Drizzle schema module so your project owns it: add the +file to your drizzle-kit schema paths and pass it to drizzlePersistence(db, { schema }). + +Options: + --out Copy the schema module into the directory. + --stdout Print the schema module. + --force Replace a divergent file when copying. + --help Show this help. +` + +interface ParsedArguments { + force: boolean + help: boolean + outDirectory?: string + stdout: boolean +} + +function parseArguments(args: ReadonlyArray): ParsedArguments { + let force = false + let help = false + let outDirectory: string | undefined + let stdout = false + + for (let index = 0; index < args.length; index++) { + const argument = args[index] + if (argument === '--force') { + force = true + } else if (argument === '--help' || argument === '-h') { + help = true + } else if (argument === '--stdout') { + stdout = true + } else if (argument === '--out') { + const value = args[index + 1] + if (!value || value.startsWith('-')) { + throw new SchemaCliError('--out requires a directory.') + } + if (outDirectory !== undefined) { + throw new SchemaCliError('--out may only be provided once.') + } + outDirectory = value + index++ + } else { + throw new SchemaCliError(`Unknown argument: ${argument ?? ''}`) + } + } + + return { force, help, outDirectory, stdout } +} + +function isMissingFileError(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +async function readExistingFile(path: string): Promise { + try { + return await readFile(path, 'utf8') + } catch (error) { + if (isMissingFileError(error)) return undefined + throw error + } +} + +/** Execute the schema asset CLI. Exported for deterministic CLI tests. */ +export async function runDrizzleSchemaCli( + args: ReadonlyArray, + output: SchemaCliOutput = { + writeStdout(value) { + process.stdout.write(value) + }, + }, +): Promise { + const parsed = parseArguments(args) + if (parsed.help) { + output.writeStdout(usage) + return + } + + const outputCount = + Number(parsed.stdout) + Number(parsed.outDirectory != null) + if (outputCount !== 1) { + throw new SchemaCliError( + 'Provide exactly one output mode: --out or --stdout.', + ) + } + if (parsed.stdout) { + if (parsed.force) { + throw new SchemaCliError('--force can only be used with --out.') + } + output.writeStdout(`${drizzleSchemaSource.trimEnd()}\n`) + return + } + + const outDirectory = parsed.outDirectory + if (!outDirectory) { + throw new SchemaCliError('--out requires a directory.') + } + await mkdir(outDirectory, { recursive: true }) + const destination = join(outDirectory, drizzleSchemaFilename) + const existing = await readExistingFile(destination) + if (existing === drizzleSchemaSource) return + if (existing !== undefined && !parsed.force) { + throw new SchemaCliError( + `Refusing to overwrite divergent schema file: ${destination}. Re-run with --force to replace it.`, + ) + } + await writeFile(destination, drizzleSchemaSource, 'utf8') +} diff --git a/packages/ai-persistence-drizzle/src/schema-contract.ts b/packages/ai-persistence-drizzle/src/schema-contract.ts new file mode 100644 index 000000000..9a8d53319 --- /dev/null +++ b/packages/ai-persistence-drizzle/src/schema-contract.ts @@ -0,0 +1,137 @@ +/** + * Structural contract for a user-supplied TanStack AI Drizzle schema. + * + * `drizzlePersistence` accepts any schema whose tables and columns carry the + * required data shapes; table and column **database names are free**. This lets + * a project own the schema file (emitted by `tanstack-ai-drizzle-schema`), + * generate DDL through its own drizzle-kit journal — including projects using + * drizzle's `casing` name transforms — and extend tables with extra columns + * (for example an ownership `user_id`) without falling out of contract. + * + * Only the columns listed here are read or written by the stores. Extra + * columns must therefore be nullable or defaulted so inserts succeed. + */ +import { Column, is } from 'drizzle-orm' +import { SQLiteTable } from 'drizzle-orm/sqlite-core' +import type { AnySQLiteColumn } from 'drizzle-orm/sqlite-core' +import type { InterruptRecord, RunStatus } from '@tanstack/ai-persistence' +import type { ModelMessage, TokenUsage } from '@tanstack/ai' + +/** + * A column whose decoded (`data`) type is `TData`. Name, table name, and + * driver specifics are intentionally unconstrained. + */ +type AiColumn = AnySQLiteColumn<{ data: TData }> + +type AiTable = SQLiteTable & TColumns + +/** + * The schema shape `drizzlePersistence` can operate over. `schema` exported + * from this package satisfies it, as does the file emitted by the + * `tanstack-ai-drizzle-schema` CLI. SQLite-only (the columns are Drizzle + * SQLite columns), which the name makes explicit. + */ +export interface TanstackAiSqliteSchema { + messages: AiTable<{ + threadId: AiColumn + messagesJson: AiColumn> + }> + runs: AiTable<{ + runId: AiColumn + threadId: AiColumn + status: AiColumn + startedAt: AiColumn + finishedAt: AiColumn + error: AiColumn + usageJson: AiColumn + }> + interrupts: AiTable<{ + interruptId: AiColumn + runId: AiColumn + threadId: AiColumn + status: AiColumn + requestedAt: AiColumn + resolvedAt: AiColumn + payloadJson: AiColumn> + responseJson: AiColumn + }> + metadata: AiTable<{ + scope: AiColumn + key: AiColumn + valueJson: AiColumn + }> +} + +/** + * @deprecated Renamed to {@link TanstackAiSqliteSchema} — the contract is + * SQLite-only, which the old name did not signal. + */ +export type TanstackAiSchema = TanstackAiSqliteSchema + +const requiredColumns: { + [K in keyof TanstackAiSqliteSchema]: ReadonlyArray +} = { + messages: ['threadId', 'messagesJson'], + runs: [ + 'runId', + 'threadId', + 'status', + 'startedAt', + 'finishedAt', + 'error', + 'usageJson', + ], + interrupts: [ + 'interruptId', + 'runId', + 'threadId', + 'status', + 'requestedAt', + 'resolvedAt', + 'payloadJson', + 'responseJson', + ], + metadata: ['scope', 'key', 'valueJson'], +} + +const tableKeys = Object.keys(requiredColumns) as Array< + keyof TanstackAiSqliteSchema +> + +/** A user-supplied schema failed the {@link TanstackAiSqliteSchema} contract. */ +export class DrizzleSchemaError extends Error { + constructor(problems: ReadonlyArray) { + super( + `Invalid TanStack AI Drizzle schema:\n${problems + .map((problem) => ` - ${problem}`) + .join('\n')}`, + ) + this.name = 'DrizzleSchemaError' + } +} + +/** + * Assert `input` structurally satisfies the store contract at runtime: every + * table is a Drizzle SQLite table and carries every column property the + * stores reference. Column data shapes are enforced by the + * {@link TanstackAiSqliteSchema} type at compile time and are not re-checked here. + */ +export function assertTanstackAiSchema(input: TanstackAiSqliteSchema): void { + const problems: Array = [] + for (const tableKey of tableKeys) { + const table: unknown = input[tableKey] + if (!is(table, SQLiteTable)) { + problems.push(`\`${tableKey}\` is not a Drizzle SQLite table.`) + continue + } + for (const columnKey of requiredColumns[tableKey]) { + const column: unknown = Reflect.get(table, columnKey) + if (!is(column, Column)) { + problems.push( + `\`${tableKey}.${columnKey}\` is missing or not a Drizzle column.`, + ) + } + } + } + if (problems.length > 0) throw new DrizzleSchemaError(problems) +} diff --git a/packages/ai-persistence-drizzle/src/schema-source.ts b/packages/ai-persistence-drizzle/src/schema-source.ts new file mode 100644 index 000000000..fbce6e2cd --- /dev/null +++ b/packages/ai-persistence-drizzle/src/schema-source.ts @@ -0,0 +1,12 @@ +import source from './assets/tanstack-ai-schema.ts?raw' + +/** Filename used by the schema copy CLI. */ +export const drizzleSchemaFilename = 'tanstack-ai-schema.ts' + +/** + * Source text of the user-facing TanStack AI Drizzle schema module, emitted by + * `tanstack-ai-drizzle-schema`. Structurally identical to this package's + * bundled `schema` (a test enforces it); the copy exists so a project's own + * drizzle-kit journal can own the DDL. + */ +export const drizzleSchemaSource: string = source diff --git a/packages/ai-persistence-drizzle/src/schema.ts b/packages/ai-persistence-drizzle/src/schema.ts new file mode 100644 index 000000000..97ffc79a4 --- /dev/null +++ b/packages/ai-persistence-drizzle/src/schema.ts @@ -0,0 +1,78 @@ +/** + * Drizzle schema for the TanStack AI **state** persistence contract. + * + * Each table mirrors the corresponding record in + * `@tanstack/ai-persistence`'s `types.ts` column-for-column. JSON-valued fields + * are stored in `*_json` text columns; epoch millisecond timestamps are stored + * as integers. + * + * This schema is the single source of truth for migrations: run + * `pnpm db:generate` (drizzle-kit) after any change here. It is also exported + * from the package so bring-your-own-drizzle users can drive their own + * migration workflow against it. + * + * PROVENANCE: `db:generate` emits DDL into `drizzle/`, but the runtime's + * `sqliteMigrations` (and the D1 sibling) load the DUPLICATE at + * `src/assets/0000_tanstack_ai_initial.sql`. `db:generate` alone does NOT touch + * that asset — copy the regenerated `drizzle/0000_tanstack_ai_initial.sql` over + * it, or the shipped migration goes stale. The package-contract test "keeps the + * shipped drizzle-kit migration equal to the embedded asset" fails on drift. + * + * Also keep this in sync with the sibling Prisma schema fragment + * (`@tanstack/ai-persistence-prisma`'s `src/assets/tanstack-ai.prisma`). + */ +import { integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import type { InterruptRecord, RunStatus } from '@tanstack/ai-persistence' +import type { ModelMessage, TokenUsage } from '@tanstack/ai' + +/** Thread message history (`MessageStore`). */ +export const messages = sqliteTable('messages', { + threadId: text('thread_id').primaryKey(), + messagesJson: text('messages_json', { mode: 'json' }) + .$type>() + .notNull(), +}) + +/** Run lifecycle records (`RunStore`). */ +export const runs = sqliteTable('runs', { + runId: text('run_id').primaryKey(), + threadId: text('thread_id').notNull(), + status: text('status').$type().notNull(), + startedAt: integer('started_at').notNull(), + finishedAt: integer('finished_at'), + error: text('error'), + usageJson: text('usage_json', { mode: 'json' }).$type(), +}) + +/** Interrupt / approval records (`InterruptStore`). */ +export const interrupts = sqliteTable('interrupts', { + interruptId: text('interrupt_id').primaryKey(), + runId: text('run_id').notNull(), + threadId: text('thread_id').notNull(), + status: text('status').$type().notNull(), + requestedAt: integer('requested_at').notNull(), + resolvedAt: integer('resolved_at'), + payloadJson: text('payload_json', { mode: 'json' }) + .$type>() + .notNull(), + responseJson: text('response_json', { mode: 'json' }).$type(), +}) + +/** Scoped key/value metadata (`MetadataStore`). */ +export const metadata = sqliteTable( + 'metadata', + { + scope: text('scope').notNull(), + key: text('key').notNull(), + valueJson: text('value_json', { mode: 'json' }).$type().notNull(), + }, + (table) => [primaryKey({ columns: [table.scope, table.key] })], +) + +/** The full state schema, for `drizzlePersistence(db)` and drizzle-kit. */ +export const schema = { + messages, + runs, + interrupts, + metadata, +} diff --git a/packages/ai-persistence-drizzle/src/sqlite-migrations.ts b/packages/ai-persistence-drizzle/src/sqlite-migrations.ts new file mode 100644 index 000000000..1866c339d --- /dev/null +++ b/packages/ai-persistence-drizzle/src/sqlite-migrations.ts @@ -0,0 +1,68 @@ +import type { DatabaseSync } from 'node:sqlite' +import type { SqliteMigration } from './migrations' + +const MIGRATIONS_TABLE = '__tanstack_ai_migrations' + +/** Error raised when a bundled SQLite migration cannot be applied atomically. */ +export class SqliteMigrationError extends Error { + readonly migrationId: string + + constructor(migrationId: string, cause: unknown) { + const detail = cause instanceof Error ? cause.message : String(cause) + super(`Failed to apply SQLite migration "${migrationId}": ${detail}`, { + cause, + }) + this.name = 'SqliteMigrationError' + this.migrationId = migrationId + } +} + +/** + * Apply ordered migrations with each migration SQL and bookkeeping insert in + * the same transaction. A failed migration leaves no partial schema changes or + * applied marker, so retrying after the underlying issue is corrected is safe. + */ +export function applySqliteMigrations( + database: DatabaseSync, + migrations: ReadonlyArray, +): void { + database.exec(` + CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} ( + migration_id TEXT PRIMARY KEY NOT NULL, + applied_at INTEGER NOT NULL + ) + `) + + const findApplied = database.prepare( + `SELECT migration_id FROM ${MIGRATIONS_TABLE} WHERE migration_id = ?`, + ) + const recordApplied = database.prepare( + `INSERT INTO ${MIGRATIONS_TABLE} (migration_id, applied_at) VALUES (?, ?)`, + ) + + for (const migration of migrations) { + database.exec('BEGIN IMMEDIATE') + try { + if (findApplied.get(migration.id) !== undefined) { + database.exec('COMMIT') + continue + } + database.exec(migration.sql) + recordApplied.run(migration.id, Date.now()) + database.exec('COMMIT') + } catch (error) { + try { + database.exec('ROLLBACK') + } catch (rollbackError) { + throw new SqliteMigrationError( + migration.id, + new AggregateError( + [error, rollbackError], + 'Migration failed and its transaction could not be rolled back.', + ), + ) + } + throw new SqliteMigrationError(migration.id, error) + } + } +} diff --git a/packages/ai-persistence-drizzle/src/sqlite.ts b/packages/ai-persistence-drizzle/src/sqlite.ts new file mode 100644 index 000000000..0774c3ff0 --- /dev/null +++ b/packages/ai-persistence-drizzle/src/sqlite.ts @@ -0,0 +1,82 @@ +/** Node-only SQLite convenience factory for TanStack AI persistence. */ +import { mkdirSync } from 'node:fs' +import { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { DatabaseSync } from 'node:sqlite' +import { drizzle } from 'drizzle-orm/sqlite-proxy' +import { sqliteMigrations } from './migrations' +import { applySqliteMigrations } from './sqlite-migrations' +import { drizzlePersistence } from './index' + +export { SqliteMigrationError } from './sqlite-migrations' + +export interface SqlitePersistenceOptions { + /** `:memory:`, a filesystem path, or a `file:`-prefixed filesystem path. */ + url: string + /** Apply the bundled TanStack AI migrations before creating stores. */ + migrate?: boolean +} + +/** Build persistence over Node's built-in SQLite driver. */ +export function sqlitePersistence(options: SqlitePersistenceOptions) { + const filename = normalizeSqliteUrl(options.url) + ensureParentDirectory(filename) + const sqlite = new DatabaseSync(filename) + try { + if (options.migrate) applySqliteMigrations(sqlite, sqliteMigrations) + } catch (error) { + sqlite.close() + throw error + } + + const db = drizzle((sql, params, method) => { + const statement = sqlite.prepare(sql) + if (method === 'run') { + statement.run(...params) + return Promise.resolve({ rows: [] }) + } + if (method === 'get') { + const row = statement.get(...params) + return Promise.resolve({ rows: row ? Object.values(row) : [] }) + } + const rows = statement.all(...params) + return Promise.resolve({ rows: rows.map((row) => Object.values(row)) }) + }) + + const persistence = drizzlePersistence(db) + let closed = false + return { + ...persistence, + close() { + if (closed) return + sqlite.close() + closed = true + }, + } +} + +function normalizeSqliteUrl(url: string): string { + if (url === ':memory:' || url === 'file::memory:') return ':memory:' + if (url.startsWith('file://')) return validateFilename(fileURLToPath(url)) + if (url.startsWith('file:')) { + return validateFilename(url.slice('file:'.length)) + } + const isWindowsPath = /^[A-Za-z]:[\\/]/.test(url) + if (!isWindowsPath && /^[A-Za-z][A-Za-z\d+.-]*:/.test(url)) { + throw new Error(`Unsupported SQLite URL scheme: ${url}`) + } + return validateFilename(url) +} + +function validateFilename(filename: string): string { + if (filename.length === 0 || filename.includes('\0')) { + throw new Error('SQLite URL must identify a non-empty filesystem path') + } + return filename +} + +function ensureParentDirectory(filename: string): void { + if (filename === ':memory:') return + const parent = dirname(filename) + if (parent !== '.') mkdirSync(parent, { recursive: true }) +} diff --git a/packages/ai-persistence-drizzle/src/stores.ts b/packages/ai-persistence-drizzle/src/stores.ts new file mode 100644 index 000000000..c840cdb2a --- /dev/null +++ b/packages/ai-persistence-drizzle/src/stores.ts @@ -0,0 +1,274 @@ +/** + * AIPersistence store implementations over a Drizzle sqlite database. + * + * Each method mirrors the reference in-memory backend + * (`@tanstack/ai-persistence`'s `memory.ts`), including the insert-if-absent + * `InterruptStore.create` and `RunStore.createOrResume` semantics + * (`onConflictDoNothing`). JSON columns are handled by Drizzle's + * `text({ mode: 'json' })`. + */ +import { and, asc, eq } from 'drizzle-orm' +import type { interrupts, runs, schema } from './schema' +import type { BaseSQLiteDatabase } from 'drizzle-orm/sqlite-core' +import type { ModelMessage } from '@tanstack/ai' +import type { + InterruptRecord, + InterruptStore, + MessageStore, + MetadataStore, + RunRecord, + RunStore, +} from '@tanstack/ai-persistence' + +/** + * Any Drizzle sqlite database (better-sqlite3, libsql, node:sqlite proxy, D1, …). + * + * Typed as the schema-agnostic slice of the query builder we actually use, so a + * BYO `db` constructed with any `{ schema }` is assignable regardless of its + * `TFullSchema` (which is invariant on the full `BaseSQLiteDatabase`). + */ +export type DrizzleSqliteDb = Pick< + BaseSQLiteDatabase<'sync' | 'async', unknown>, + 'select' | 'insert' | 'update' | 'delete' +> + +/** + * @deprecated Renamed to {@link DrizzleSqliteDb} — this backend is SQLite-only + * (better-sqlite3, libsql, node:sqlite, D1). The old name did not signal that. + */ +export type DrizzleDb = DrizzleSqliteDb + +/** + * The concrete table set the stores are written against. A user-supplied + * {@link TanstackAiSchema} is validated by `assertTanstackAiSchema` and then + * used through this type: only column *data* shapes matter to the store code — + * table/column database names are carried by the runtime objects, so a schema + * with different names (drizzle `casing` transforms, renamed tables, extra + * app-owned columns) produces correct SQL through the same code paths. + */ +export type TanstackAiTables = typeof schema + +export function createMessageStore( + db: DrizzleSqliteDb, + { messages }: TanstackAiTables, +): MessageStore { + return { + async loadThread(threadId) { + const rows = await db + .select({ messagesJson: messages.messagesJson }) + .from(messages) + .where(eq(messages.threadId, threadId)) + return rows[0]?.messagesJson ?? [] + }, + async saveThread(threadId, msgs: Array) { + await db + .insert(messages) + .values({ threadId, messagesJson: msgs }) + .onConflictDoUpdate({ + target: messages.threadId, + set: { messagesJson: msgs }, + }) + }, + } +} + +function mapRun(row: typeof runs.$inferSelect): RunRecord { + return { + runId: row.runId, + threadId: row.threadId, + status: row.status, + startedAt: row.startedAt, + ...(row.finishedAt != null ? { finishedAt: row.finishedAt } : {}), + ...(row.error != null ? { error: row.error } : {}), + ...(row.usageJson != null ? { usage: row.usageJson } : {}), + } +} + +export function createRunStore( + db: DrizzleSqliteDb, + { runs }: TanstackAiTables, +): RunStore { + const store: RunStore = { + async createOrResume(input) { + const existing = await store.get(input.runId) + if (existing) return existing + const record: RunRecord = { + runId: input.runId, + threadId: input.threadId, + status: input.status ?? 'running', + startedAt: input.startedAt, + } + await db + .insert(runs) + .values({ + runId: record.runId, + threadId: record.threadId, + status: record.status, + startedAt: record.startedAt, + }) + .onConflictDoNothing({ target: runs.runId }) + return (await store.get(input.runId)) ?? record + }, + async update(runId, patch) { + const set: Partial = {} + if (patch.status !== undefined) set.status = patch.status + if (patch.finishedAt !== undefined) set.finishedAt = patch.finishedAt + if (patch.error !== undefined) set.error = patch.error + if (patch.usage !== undefined) set.usageJson = patch.usage + if (Object.keys(set).length === 0) return + await db.update(runs).set(set).where(eq(runs.runId, runId)) + }, + async get(runId) { + const rows = await db.select().from(runs).where(eq(runs.runId, runId)) + const row = rows[0] + return row ? mapRun(row) : null + }, + } + return store +} + +function mapInterrupt(row: typeof interrupts.$inferSelect): InterruptRecord { + return { + interruptId: row.interruptId, + runId: row.runId, + threadId: row.threadId, + status: row.status, + requestedAt: row.requestedAt, + ...(row.resolvedAt != null ? { resolvedAt: row.resolvedAt } : {}), + payload: row.payloadJson, + ...(row.responseJson != null ? { response: row.responseJson } : {}), + } +} + +export function createInterruptStore( + db: DrizzleSqliteDb, + { interrupts }: TanstackAiTables, +): InterruptStore { + return { + async create(record) { + await db + .insert(interrupts) + .values({ + interruptId: record.interruptId, + runId: record.runId, + threadId: record.threadId, + status: 'pending', + requestedAt: record.requestedAt, + payloadJson: record.payload, + responseJson: record.response ?? null, + }) + .onConflictDoNothing({ target: interrupts.interruptId }) + }, + async resolve(interruptId, response) { + await db + .update(interrupts) + .set({ + status: 'resolved', + resolvedAt: Date.now(), + responseJson: response ?? null, + }) + .where(eq(interrupts.interruptId, interruptId)) + }, + async cancel(interruptId) { + await db + .update(interrupts) + .set({ status: 'cancelled', resolvedAt: Date.now() }) + .where(eq(interrupts.interruptId, interruptId)) + }, + async get(interruptId) { + const rows = await db + .select() + .from(interrupts) + .where(eq(interrupts.interruptId, interruptId)) + const row = rows[0] + return row ? mapInterrupt(row) : null + }, + async list(threadId) { + const rows = await db + .select() + .from(interrupts) + .where(eq(interrupts.threadId, threadId)) + .orderBy(asc(interrupts.requestedAt)) + return rows.map(mapInterrupt) + }, + async listPending(threadId) { + const rows = await db + .select() + .from(interrupts) + .where( + and( + eq(interrupts.threadId, threadId), + eq(interrupts.status, 'pending'), + ), + ) + .orderBy(asc(interrupts.requestedAt)) + return rows.map(mapInterrupt) + }, + async listByRun(runId) { + const rows = await db + .select() + .from(interrupts) + .where(eq(interrupts.runId, runId)) + .orderBy(asc(interrupts.requestedAt)) + return rows.map(mapInterrupt) + }, + async listPendingByRun(runId) { + const rows = await db + .select() + .from(interrupts) + .where( + and(eq(interrupts.runId, runId), eq(interrupts.status, 'pending')), + ) + .orderBy(asc(interrupts.requestedAt)) + return rows.map(mapInterrupt) + }, + } +} + +function assertStorableMetadata(value: unknown): void { + if (value == null) { + throw new TypeError( + `TanStack AI metadata values must be defined, non-null JSON; received ${ + value === undefined ? '`undefined`' : '`null`' + }. Use \`delete(scope, key)\` to clear a value.`, + ) + } +} + +export function createMetadataStore( + db: DrizzleSqliteDb, + { metadata }: TanstackAiTables, +): MetadataStore { + return { + async get(scope, key) { + const rows = await db + .select({ valueJson: metadata.valueJson }) + .from(metadata) + .where(and(eq(metadata.scope, scope), eq(metadata.key, key))) + const row = rows[0] + return row ? row.valueJson : null + }, + async set(scope, key, value) { + // SQL backends store JSON text in a NOT NULL column and cannot persist a + // nullish value: `text({ mode: 'json' })` binds a JS `null` as SQL NULL + // (it never serializes it to the text `"null"`), and `undefined` has no + // JSON at all — both violate NOT NULL. Reject nullish with a clear error + // (consistent with the sibling Prisma backend) instead of a cryptic + // driver failure. Unlike the in-memory reference, which round-trips + // nullish; use `delete` to clear. + assertStorableMetadata(value) + await db + .insert(metadata) + .values({ scope, key, valueJson: value }) + .onConflictDoUpdate({ + target: [metadata.scope, metadata.key], + set: { valueJson: value }, + }) + }, + async delete(scope, key) { + await db + .delete(metadata) + .where(and(eq(metadata.scope, scope), eq(metadata.key, key))) + }, + } +} diff --git a/packages/ai-persistence-drizzle/tests/api-types.test-d.ts b/packages/ai-persistence-drizzle/tests/api-types.test-d.ts new file mode 100644 index 000000000..bf414cb40 --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/api-types.test-d.ts @@ -0,0 +1,39 @@ +import { expectTypeOf } from 'vitest' +import type { DrizzleD1Database } from 'drizzle-orm/d1' +import type { + InterruptStore, + MessageStore, + MetadataStore, + RunStore, +} from '@tanstack/ai-persistence' +import { drizzlePersistence, schema } from '../src/index' +import { sqlitePersistence } from '../src/sqlite' +import { schema as emittedSchema } from '../src/assets/tanstack-ai-schema' +import { variantSchema } from './variant-schema' +import type { TanstackAiSqliteSchema } from '../src/index' + +declare const d1Database: DrizzleD1Database +const d1Persistence = drizzlePersistence(d1Database) +expectTypeOf(d1Persistence.stores.messages).toEqualTypeOf() +expectTypeOf(d1Persistence.stores.runs).toEqualTypeOf() +expectTypeOf(d1Persistence.stores.interrupts).toEqualTypeOf() +expectTypeOf(d1Persistence.stores.metadata).toEqualTypeOf() +// No `locks` store: this backend has no distributed lock (see drizzlePersistence). +expectTypeOf(d1Persistence.stores).not.toHaveProperty('locks') + +const sqlite = sqlitePersistence({ url: ':memory:', migrate: true }) +expectTypeOf(sqlite.stores).toEqualTypeOf() +expectTypeOf(sqlite.close).toEqualTypeOf<() => void>() + +// The bundled schema, the emitted schema asset, and a renamed/extended variant +// must all satisfy the injectable schema contract. +expectTypeOf(schema).toExtend() +expectTypeOf(emittedSchema).toExtend() +expectTypeOf(variantSchema).toExtend() + +const customPersistence = drizzlePersistence(d1Database, { + schema: variantSchema, +}) +expectTypeOf(customPersistence.stores).toEqualTypeOf< + typeof d1Persistence.stores +>() diff --git a/packages/ai-persistence-drizzle/tests/custom-schema.test.ts b/packages/ai-persistence-drizzle/tests/custom-schema.test.ts new file mode 100644 index 000000000..7f94e73d0 --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/custom-schema.test.ts @@ -0,0 +1,113 @@ +import { DatabaseSync } from 'node:sqlite' +import { describe, expect, it } from 'vitest' +import { eq } from 'drizzle-orm' +import { sqliteTable, text } from 'drizzle-orm/sqlite-core' +import { drizzle } from 'drizzle-orm/sqlite-proxy' +import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' +import { DrizzleSchemaError, drizzlePersistence, schema } from '../src/index' +import { variantDdl, variantSchema } from './variant-schema' +import type { TanstackAiSqliteSchema } from '../src/index' +import type { DrizzleSqliteDb } from '../src/index' + +function createVariantDb(): { db: DrizzleSqliteDb; sqlite: DatabaseSync } { + const sqlite = new DatabaseSync(':memory:') + for (const statement of variantDdl) sqlite.exec(statement) + const db = drizzle((sql, params, method) => { + const statement = sqlite.prepare(sql) + if (method === 'run') { + statement.run(...params) + return Promise.resolve({ rows: [] }) + } + if (method === 'get') { + const row = statement.get(...params) + return Promise.resolve({ rows: row ? Object.values(row) : [] }) + } + const rows = statement.all(...params) + return Promise.resolve({ rows: rows.map((row) => Object.values(row)) }) + }) + return { db, sqlite } +} + +// The full store contract must hold when the runtime operates over a schema +// whose table and column database names all differ from the bundled ones. +runPersistenceConformance( + 'drizzle-sqlite (injected variant schema)', + () => drizzlePersistence(createVariantDb().db, { schema: variantSchema }), + // This backend has no distributed lock primitive. + { skip: ['locks'] }, +) + +describe('drizzlePersistence with an injected schema', () => { + it('writes through the injected table and column names', async () => { + const { db, sqlite } = createVariantDb() + const persistence = drizzlePersistence(db, { schema: variantSchema }) + + await persistence.stores.messages.saveThread('thread-1', [ + { role: 'user', content: 'hello' }, + ]) + + const row = sqlite + .prepare('SELECT threadId, messagesJson FROM app_ai_messages') + .get() + expect(row?.threadId).toBe('thread-1') + expect(JSON.parse(String(row?.messagesJson))).toEqual([ + { role: 'user', content: 'hello' }, + ]) + }) + + it('coexists with app-owned columns on the same tables', async () => { + const { db, sqlite } = createVariantDb() + const persistence = drizzlePersistence(db, { schema: variantSchema }) + + await persistence.stores.messages.saveThread('thread-owned', [ + { role: 'user', content: 'mine' }, + ]) + // The app claims ownership through its own extension column. + await db + .update(variantSchema.messages) + .set({ userId: 'user-42' }) + .where(eq(variantSchema.messages.threadId, 'thread-owned')) + + // Store updates leave the app-owned column untouched. + await persistence.stores.messages.saveThread('thread-owned', [ + { role: 'user', content: 'mine' }, + { role: 'assistant', content: 'yours' }, + ]) + + const row = sqlite + .prepare('SELECT userId FROM app_ai_messages WHERE threadId = ?') + .get('thread-owned') + expect(row?.userId).toBe('user-42') + expect( + await persistence.stores.messages.loadThread('thread-owned'), + ).toHaveLength(2) + }) + + it('rejects a schema with missing tables or columns', () => { + const { db } = createVariantDb() + + const { metadata: _dropped, ...withoutMetadata } = schema + expect(() => + drizzlePersistence(db, { + schema: withoutMetadata as unknown as TanstackAiSqliteSchema, + }), + ).toThrow(DrizzleSchemaError) + expect(() => + drizzlePersistence(db, { + schema: withoutMetadata as unknown as TanstackAiSqliteSchema, + }), + ).toThrow(/`metadata` is not a Drizzle SQLite table/) + + const incompleteMessages = sqliteTable('messages', { + threadId: text('thread_id').primaryKey(), + }) + expect(() => + drizzlePersistence(db, { + schema: { + ...schema, + messages: incompleteMessages, + } as unknown as TanstackAiSqliteSchema, + }), + ).toThrow(/`messages\.messagesJson` is missing/) + }) +}) diff --git a/packages/ai-persistence-drizzle/tests/drizzle.conformance.test.ts b/packages/ai-persistence-drizzle/tests/drizzle.conformance.test.ts new file mode 100644 index 000000000..7c6e03df4 --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/drizzle.conformance.test.ts @@ -0,0 +1,9 @@ +import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' +import { sqlitePersistence } from '../src/sqlite' + +runPersistenceConformance( + 'drizzle-sqlite', + () => sqlitePersistence({ url: ':memory:', migrate: true }), + // This backend has no distributed lock primitive. + { skip: ['locks'] }, +) diff --git a/packages/ai-persistence-drizzle/tests/migration-cli.test.ts b/packages/ai-persistence-drizzle/tests/migration-cli.test.ts new file mode 100644 index 000000000..2743a2cbb --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/migration-cli.test.ts @@ -0,0 +1,65 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { sqliteMigrations } from '../src/migrations' +import { runDrizzleMigrationsCli } from '../src/migration-cli' + +const temporaryDirectories: Array = [] + +async function createTemporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'tanstack-ai-drizzle-cli-')) + temporaryDirectories.push(directory) + return directory +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ) +}) + +describe('drizzle migrations CLI', () => { + it('prints canonical migration SQL to stdout', async () => { + let output = '' + await runDrizzleMigrationsCli(['--stdout'], { + writeStdout(value) { + output += value + }, + }) + + expect(output).toBe(`${sqliteMigrations[0]?.sql.trimEnd()}\n`) + }) + + it('copies migrations without overwriting divergent files by default', async () => { + const directory = await createTemporaryDirectory() + await runDrizzleMigrationsCli(['--out', directory]) + + const migration = sqliteMigrations[0] + expect(migration).toBeDefined() + if (!migration) return + const destination = join(directory, migration.filename) + expect(await readFile(destination, 'utf8')).toBe(migration.sql) + + await writeFile(destination, 'user-owned contents', 'utf8') + await expect(runDrizzleMigrationsCli(['--out', directory])).rejects.toThrow( + /refusing to overwrite/i, + ) + expect(await readFile(destination, 'utf8')).toBe('user-owned contents') + + await runDrizzleMigrationsCli(['--out', directory, '--force']) + expect(await readFile(destination, 'utf8')).toBe(migration.sql) + }) + + it('fails on ambiguous or incomplete output options', async () => { + const directory = await createTemporaryDirectory() + await expect(runDrizzleMigrationsCli([])).rejects.toThrow( + /--out.*--stdout/i, + ) + await expect( + runDrizzleMigrationsCli(['--stdout', '--out', directory]), + ).rejects.toThrow(/exactly one/i) + }) +}) diff --git a/packages/ai-persistence-drizzle/tests/migrations.test.ts b/packages/ai-persistence-drizzle/tests/migrations.test.ts new file mode 100644 index 000000000..f9a016907 --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/migrations.test.ts @@ -0,0 +1,101 @@ +import { DatabaseSync } from 'node:sqlite' +import { afterEach, describe, expect, it } from 'vitest' +import { sqliteMigrations } from '../src/migrations' +import { applySqliteMigrations } from '../src/sqlite-migrations' + +const databases: Array = [] + +function createDatabase(): DatabaseSync { + const database = new DatabaseSync(':memory:') + databases.push(database) + return database +} + +afterEach(() => { + for (const database of databases.splice(0)) database.close() +}) + +describe('sqlite migrations', () => { + it('exposes an ordered canonical manifest and applies it idempotently', () => { + expect(sqliteMigrations.map((migration) => migration.id)).toEqual([ + '0000_tanstack_ai_initial', + ]) + + const database = createDatabase() + applySqliteMigrations(database, sqliteMigrations) + applySqliteMigrations(database, sqliteMigrations) + + const tableNames = database + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name", + ) + .all() + .map((row) => row.name) + expect(tableNames).toEqual( + expect.arrayContaining([ + '__tanstack_ai_migrations', + 'interrupts', + 'messages', + 'metadata', + 'runs', + ]), + ) + expect( + database + .prepare('SELECT migration_id FROM __tanstack_ai_migrations') + .all(), + ).toEqual([{ migration_id: '0000_tanstack_ai_initial' }]) + }) + + it('rolls back migration SQL and bookkeeping together, then permits retry', () => { + const database = createDatabase() + const failingMigration = { + id: '0000_retry_test', + filename: '0000_retry_test.sql', + sql: ` + CREATE TABLE retry_target (id INTEGER PRIMARY KEY); + CREATE TRIGGER reject_migration_bookkeeping + BEFORE INSERT ON __tanstack_ai_migrations + BEGIN + SELECT RAISE(ABORT, 'bookkeeping failed'); + END; + `, + } + + expect(() => applySqliteMigrations(database, [failingMigration])).toThrow( + 'bookkeeping failed', + ) + expect( + database + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'retry_target'", + ) + .get(), + ).toBeUndefined() + expect( + database + .prepare('SELECT migration_id FROM __tanstack_ai_migrations') + .all(), + ).toEqual([]) + + applySqliteMigrations(database, [ + { + ...failingMigration, + sql: 'CREATE TABLE retry_target (id INTEGER PRIMARY KEY);', + }, + ]) + + expect( + database + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'retry_target'", + ) + .get(), + ).toEqual({ name: 'retry_target' }) + expect( + database + .prepare('SELECT migration_id FROM __tanstack_ai_migrations') + .all(), + ).toEqual([{ migration_id: '0000_retry_test' }]) + }) +}) diff --git a/packages/ai-persistence-drizzle/tests/package-contract.test.ts b/packages/ai-persistence-drizzle/tests/package-contract.test.ts new file mode 100644 index 000000000..dfcdb7a6b --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/package-contract.test.ts @@ -0,0 +1,67 @@ +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import packageJson from '../package.json' + +describe('drizzle package contract', () => { + it('publishes the edge root, Node sqlite subpath, CLI, and migration assets', () => { + expect(packageJson.exports).toEqual({ + '.': { + types: './dist/esm/index.d.ts', + import: './dist/esm/index.js', + }, + './sqlite': { + types: './dist/esm/sqlite.d.ts', + import: './dist/esm/sqlite.js', + }, + }) + expect(packageJson.bin).toEqual({ + 'tanstack-ai-drizzle-migrations': + './bin/tanstack-ai-drizzle-migrations.mjs', + 'tanstack-ai-drizzle-schema': './bin/tanstack-ai-drizzle-schema.mjs', + }) + expect(packageJson.files).toEqual( + expect.arrayContaining(['bin', 'dist', 'src', 'drizzle']), + ) + }) + + it('keeps Node built-ins and Buffer out of the root import graph', async () => { + const rootFiles = [ + 'index.ts', + 'migrations.ts', + 'schema.ts', + 'schema-contract.ts', + 'schema-source.ts', + 'stores.ts', + ] + for (const filename of rootFiles) { + const contents = await readFile( + fileURLToPath(new URL(`../src/${filename}`, import.meta.url)), + 'utf8', + ) + expect(contents, filename).not.toMatch(/from ['"]node:/) + expect(contents, filename).not.toMatch(/\bBuffer\b/) + } + const root = await readFile( + fileURLToPath(new URL('../src/index.ts', import.meta.url)), + 'utf8', + ) + expect(root).not.toMatch(/from ['"].*sqlite/) + }) + + it('keeps the shipped drizzle-kit migration equal to the embedded asset', async () => { + const embedded = await readFile( + fileURLToPath( + new URL('../src/assets/0000_tanstack_ai_initial.sql', import.meta.url), + ), + 'utf8', + ) + const shipped = await readFile( + fileURLToPath( + new URL('../drizzle/0000_tanstack_ai_initial.sql', import.meta.url), + ), + 'utf8', + ) + expect(shipped).toBe(embedded) + }) +}) diff --git a/packages/ai-persistence-drizzle/tests/schema-cli.test.ts b/packages/ai-persistence-drizzle/tests/schema-cli.test.ts new file mode 100644 index 000000000..c24afee87 --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/schema-cli.test.ts @@ -0,0 +1,67 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { drizzleSchemaFilename, drizzleSchemaSource } from '../src/index' +import { runDrizzleSchemaCli } from '../src/schema-cli' + +const temporaryDirectories: Array = [] + +async function createTemporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'tanstack-ai-drizzle-schema-')) + temporaryDirectories.push(directory) + return directory +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ) +}) + +describe('Drizzle schema CLI', () => { + it('prints the schema module to stdout', async () => { + let output = '' + await runDrizzleSchemaCli(['--stdout'], { + writeStdout(value) { + output += value + }, + }) + expect(output).toBe(`${drizzleSchemaSource.trimEnd()}\n`) + expect(output).toContain('sqliteTable') + expect(output).toContain('drizzlePersistence(db, { schema })') + }) + + it('copies the schema without overwriting divergent files by default', async () => { + const directory = await createTemporaryDirectory() + await runDrizzleSchemaCli(['--out', directory]) + + const destination = join(directory, drizzleSchemaFilename) + expect(await readFile(destination, 'utf8')).toBe(drizzleSchemaSource) + + // Re-running against an identical file is a no-op. + await runDrizzleSchemaCli(['--out', directory]) + + await writeFile(destination, 'user-owned contents', 'utf8') + await expect(runDrizzleSchemaCli(['--out', directory])).rejects.toThrow( + /refusing to overwrite/i, + ) + expect(await readFile(destination, 'utf8')).toBe('user-owned contents') + + await runDrizzleSchemaCli(['--out', directory, '--force']) + expect(await readFile(destination, 'utf8')).toBe(drizzleSchemaSource) + }) + + it('fails on ambiguous or incomplete output options', async () => { + const directory = await createTemporaryDirectory() + await expect(runDrizzleSchemaCli([])).rejects.toThrow(/--out.*--stdout/i) + await expect( + runDrizzleSchemaCli(['--stdout', '--out', directory]), + ).rejects.toThrow(/exactly one/i) + await expect(runDrizzleSchemaCli(['--stdout', '--force'])).rejects.toThrow( + /--force can only be used with --out/i, + ) + }) +}) diff --git a/packages/ai-persistence-drizzle/tests/schema-source.test.ts b/packages/ai-persistence-drizzle/tests/schema-source.test.ts new file mode 100644 index 000000000..f72716a3f --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/schema-source.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { getTableConfig } from 'drizzle-orm/sqlite-core' +import { schema as bundled } from '../src/schema' +import { schema as emitted } from '../src/assets/tanstack-ai-schema' +import type { SQLiteTable } from 'drizzle-orm/sqlite-core' + +function normalize(table: SQLiteTable) { + const config = getTableConfig(table) + return { + name: config.name, + columns: config.columns.map((column) => ({ + name: column.name, + sqlType: column.getSQLType(), + notNull: column.notNull, + primary: column.primary, + })), + primaryKeys: config.primaryKeys.map((primaryKey) => + primaryKey.columns.map((column) => column.name), + ), + } +} + +describe('emitted schema asset', () => { + it('is structurally identical to the bundled schema', () => { + const tableKeys = Object.keys(bundled) as Array + expect(Object.keys(emitted)).toEqual(Object.keys(bundled)) + for (const key of tableKeys) { + expect(normalize(emitted[key]), key).toEqual(normalize(bundled[key])) + } + }) +}) diff --git a/packages/ai-persistence-drizzle/tests/sqlite.test.ts b/packages/ai-persistence-drizzle/tests/sqlite.test.ts new file mode 100644 index 000000000..299081cc0 --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/sqlite.test.ts @@ -0,0 +1,53 @@ +import { mkdtemp, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { sqlitePersistence } from '../src/sqlite' + +const temporaryDirectories: Array = [] + +afterEach(async () => { + for (const directory of temporaryDirectories.splice(0)) { + await rm(directory, { recursive: true, force: true }) + } +}) + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'tanstack-ai-sqlite-')) + temporaryDirectories.push(directory) + return directory +} + +describe('sqlitePersistence', () => { + it('creates a missing parent directory for a filesystem database', async () => { + const root = await temporaryDirectory() + const databasePath = join(root, 'nested', 'state.sqlite') + const persistence = sqlitePersistence({ + url: `file:${databasePath}`, + migrate: true, + }) + + try { + await expect(stat(databasePath)).resolves.toMatchObject({ + isFile: expect.any(Function), + }) + } finally { + persistence.close() + } + }) + + it('exposes an idempotent close for the database handle it owns', () => { + const persistence = sqlitePersistence({ url: ':memory:' }) + + expect(() => { + persistence.close() + persistence.close() + }).not.toThrow() + }) + + it('rejects non-file URI schemes before touching the filesystem', () => { + expect(() => + sqlitePersistence({ url: 'https://example.test/state.sqlite' }), + ).toThrow(/unsupported SQLite URL/i) + }) +}) diff --git a/packages/ai-persistence-drizzle/tests/store-behavior.test.ts b/packages/ai-persistence-drizzle/tests/store-behavior.test.ts new file mode 100644 index 000000000..f5e680a9b --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/store-behavior.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { sqlitePersistence } from '../src/sqlite' + +type Persistence = ReturnType + +const open: Array = [] + +function persistence(): Persistence { + const created = sqlitePersistence({ url: ':memory:', migrate: true }) + open.push(created) + return created +} + +afterEach(() => { + for (const created of open.splice(0)) created.close() +}) + +describe('drizzle metadata nullish values', () => { + it('rejects nullish values with a clear error instead of a NOT NULL crash', async () => { + const { stores } = persistence() + + // A NOT NULL json column cannot store nullish; surface a clear, actionable + // error (consistent with the Prisma backend) rather than a driver crash. + await expect(stores.metadata.set('scope', 'u', undefined)).rejects.toThrow( + /metadata values must be defined/, + ) + await expect(stores.metadata.set('scope', 'n', null)).rejects.toThrow( + /metadata values must be defined/, + ) + expect(await stores.metadata.get('scope', 'u')).toBeNull() + + // Falsy-but-defined values are stored and round-trip. + await stores.metadata.set('scope', 'zero', 0) + expect(await stores.metadata.get('scope', 'zero')).toBe(0) + await stores.metadata.set('scope', 'empty', '') + expect(await stores.metadata.get('scope', 'empty')).toBe('') + await stores.metadata.set('scope', 'obj', { a: 1 }) + expect(await stores.metadata.get('scope', 'obj')).toEqual({ a: 1 }) + }) +}) diff --git a/packages/ai-persistence-drizzle/tests/variant-schema.ts b/packages/ai-persistence-drizzle/tests/variant-schema.ts new file mode 100644 index 000000000..46551464d --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/variant-schema.ts @@ -0,0 +1,93 @@ +/** + * A deliberately renamed copy of the TanStack AI schema for exercising + * `drizzlePersistence(db, { schema })`: prefixed table names, camelCase column + * names (as a project using drizzle `casing`-style conventions would have), + * and an extra app-owned `userId` ownership column on the messages table. + * Structure (data shapes, nullability, primary keys) matches the contract. + */ +import { integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import type { InterruptRecord, RunStatus } from '@tanstack/ai-persistence' +import type { ModelMessage, TokenUsage } from '@tanstack/ai' + +const messages = sqliteTable('app_ai_messages', { + threadId: text('threadId').primaryKey(), + messagesJson: text('messagesJson', { mode: 'json' }) + .$type>() + .notNull(), + // App-owned extension the TanStack AI stores never read or write. + userId: text('userId'), +}) + +const runs = sqliteTable('app_ai_runs', { + runId: text('runId').primaryKey(), + threadId: text('threadId').notNull(), + status: text('status').$type().notNull(), + startedAt: integer('startedAt').notNull(), + finishedAt: integer('finishedAt'), + error: text('errorText'), + usageJson: text('usageJson', { mode: 'json' }).$type(), +}) + +const interrupts = sqliteTable('app_ai_interrupts', { + interruptId: text('interruptId').primaryKey(), + runId: text('runId').notNull(), + threadId: text('threadId').notNull(), + status: text('status').$type().notNull(), + requestedAt: integer('requestedAt').notNull(), + resolvedAt: integer('resolvedAt'), + payloadJson: text('payloadJson', { mode: 'json' }) + .$type>() + .notNull(), + responseJson: text('responseJson', { mode: 'json' }).$type(), +}) + +const metadata = sqliteTable( + 'app_ai_metadata', + { + scope: text('scopeName').notNull(), + key: text('keyName').notNull(), + valueJson: text('valueJson', { mode: 'json' }).$type().notNull(), + }, + (table) => [primaryKey({ columns: [table.scope, table.key] })], +) + +export const variantSchema = { + messages, + runs, + interrupts, + metadata, +} + +/** DDL matching {@link variantSchema}, applied directly in tests. */ +export const variantDdl: ReadonlyArray = [ + `CREATE TABLE app_ai_messages ( + threadId text PRIMARY KEY NOT NULL, + messagesJson text NOT NULL, + userId text + );`, + `CREATE TABLE app_ai_runs ( + runId text PRIMARY KEY NOT NULL, + threadId text NOT NULL, + status text NOT NULL, + startedAt integer NOT NULL, + finishedAt integer, + errorText text, + usageJson text + );`, + `CREATE TABLE app_ai_interrupts ( + interruptId text PRIMARY KEY NOT NULL, + runId text NOT NULL, + threadId text NOT NULL, + status text NOT NULL, + requestedAt integer NOT NULL, + resolvedAt integer, + payloadJson text NOT NULL, + responseJson text + );`, + `CREATE TABLE app_ai_metadata ( + scopeName text NOT NULL, + keyName text NOT NULL, + valueJson text NOT NULL, + PRIMARY KEY(scopeName, keyName) + );`, +] diff --git a/packages/ai-persistence-drizzle/tsconfig.json b/packages/ai-persistence-drizzle/tsconfig.json new file mode 100644 index 000000000..c38689f4e --- /dev/null +++ b/packages/ai-persistence-drizzle/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ai-persistence-drizzle/vite.config.ts b/packages/ai-persistence-drizzle/vite.config.ts new file mode 100644 index 000000000..05551030c --- /dev/null +++ b/packages/ai-persistence-drizzle/vite.config.ts @@ -0,0 +1,42 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + '**/types.ts', + ], + include: ['src/**/*.ts'], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: [ + './src/index.ts', + './src/sqlite.ts', + './src/cli.ts', + './src/schema-cli-main.ts', + ], + srcDir: './src', + cjs: false, + }), +) diff --git a/packages/ai-persistence-prisma/.gitignore b/packages/ai-persistence-prisma/.gitignore new file mode 100644 index 000000000..e1c30d225 --- /dev/null +++ b/packages/ai-persistence-prisma/.gitignore @@ -0,0 +1,3 @@ +# Scratch sqlite databases used to generate migrations / run tests. +prisma/*.db +prisma/*.db-journal diff --git a/packages/ai-persistence-prisma/bin/tanstack-ai-prisma-models.mjs b/packages/ai-persistence-prisma/bin/tanstack-ai-prisma-models.mjs new file mode 100644 index 000000000..0a4de6588 --- /dev/null +++ b/packages/ai-persistence-prisma/bin/tanstack-ai-prisma-models.mjs @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import '../dist/esm/cli.js' diff --git a/packages/ai-persistence-prisma/package.json b/packages/ai-persistence-prisma/package.json new file mode 100644 index 000000000..9e6b5225f --- /dev/null +++ b/packages/ai-persistence-prisma/package.json @@ -0,0 +1,91 @@ +{ + "name": "@tanstack/ai-persistence-prisma", + "version": "0.0.0", + "description": "Prisma persistence for TanStack AI with a provider-neutral models fragment and bring-your-own migrated PrismaClient.", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-persistence-prisma" + }, + "keywords": [ + "ai", + "tanstack", + "persistence", + "prisma", + "database" + ], + "type": "module", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + } + }, + "bin": { + "tanstack-ai-prisma-models": "./bin/tanstack-ai-prisma-models.mjs" + }, + "files": [ + "bin", + "dist", + "src", + "prisma/tanstack-ai.prisma" + ], + "scripts": { + "build": "vite build", + "clean": "premove ./build ./dist", + "db:generate": "prisma generate --schema ./prisma", + "lint:fix": "oxlint src --type-aware --fix", + "test:build": "publint --strict", + "test:lib": "vitest", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc", + "test:oxlint": "oxlint src --type-aware" + }, + "nx": { + "targets": { + "db:generate": { + "cache": false + }, + "build": { + "dependsOn": [ + "db:generate", + "^build" + ] + }, + "test:lib": { + "dependsOn": [ + "db:generate", + "^build" + ] + }, + "test:types": { + "dependsOn": [ + "db:generate", + "^build" + ] + }, + "test:eslint": { + "dependsOn": [ + "db:generate", + "^build" + ] + } + } + }, + "peerDependencies": { + "@prisma/client": ">=6.7.0", + "@tanstack/ai": "workspace:^", + "@tanstack/ai-persistence": "workspace:^" + }, + "devDependencies": { + "@prisma/client": "^6.19.3", + "@tanstack/ai": "workspace:*", + "@tanstack/ai-persistence": "workspace:*", + "@vitest/coverage-v8": "4.0.14", + "prisma": "^6.19.3" + } +} diff --git a/packages/ai-persistence-prisma/prisma/schema.prisma b/packages/ai-persistence-prisma/prisma/schema.prisma new file mode 100644 index 000000000..3506e60fb --- /dev/null +++ b/packages/ai-persistence-prisma/prisma/schema.prisma @@ -0,0 +1,10 @@ +// Internal test-client configuration. The models live in the provider-neutral +// sibling fragment that is shipped to package consumers. +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} diff --git a/packages/ai-persistence-prisma/prisma/tanstack-ai.prisma b/packages/ai-persistence-prisma/prisma/tanstack-ai.prisma new file mode 100644 index 000000000..aa716415b --- /dev/null +++ b/packages/ai-persistence-prisma/prisma/tanstack-ai.prisma @@ -0,0 +1,50 @@ +// Provider-neutral TanStack AI persistence models. +// +// Copy this file into a Prisma multi-file schema directory, then create and +// apply migrations using the application's selected provider and normal Prisma +// workflow. This fragment intentionally contains no datasource or generator. + +/// Thread message history (`MessageStore`). +model Message { + threadId String @id @map("thread_id") + messagesJson String @map("messages_json") + + @@map("messages") +} + +/// Run lifecycle records (`RunStore`). +model Run { + runId String @id @map("run_id") + threadId String @map("thread_id") + status String + startedAt BigInt @map("started_at") + finishedAt BigInt? @map("finished_at") + error String? + usageJson String? @map("usage_json") + + @@map("runs") +} + +/// Interrupt / approval records (`InterruptStore`). +model Interrupt { + interruptId String @id @map("interrupt_id") + runId String @map("run_id") + threadId String @map("thread_id") + status String + requestedAt BigInt @map("requested_at") + resolvedAt BigInt? @map("resolved_at") + payloadJson String @map("payload_json") + responseJson String? @map("response_json") + + @@map("interrupts") +} + +/// Scoped key/value metadata (`MetadataStore`). +model Metadata { + scope String + key String + valueJson String @map("value_json") + + @@id([scope, key]) + @@map("metadata") +} diff --git a/packages/ai-persistence-prisma/src/assets.d.ts b/packages/ai-persistence-prisma/src/assets.d.ts new file mode 100644 index 000000000..33c9a3e00 --- /dev/null +++ b/packages/ai-persistence-prisma/src/assets.d.ts @@ -0,0 +1,4 @@ +declare module '*.prisma?raw' { + const contents: string + export default contents +} diff --git a/packages/ai-persistence-prisma/src/assets/tanstack-ai.prisma b/packages/ai-persistence-prisma/src/assets/tanstack-ai.prisma new file mode 100644 index 000000000..aa716415b --- /dev/null +++ b/packages/ai-persistence-prisma/src/assets/tanstack-ai.prisma @@ -0,0 +1,50 @@ +// Provider-neutral TanStack AI persistence models. +// +// Copy this file into a Prisma multi-file schema directory, then create and +// apply migrations using the application's selected provider and normal Prisma +// workflow. This fragment intentionally contains no datasource or generator. + +/// Thread message history (`MessageStore`). +model Message { + threadId String @id @map("thread_id") + messagesJson String @map("messages_json") + + @@map("messages") +} + +/// Run lifecycle records (`RunStore`). +model Run { + runId String @id @map("run_id") + threadId String @map("thread_id") + status String + startedAt BigInt @map("started_at") + finishedAt BigInt? @map("finished_at") + error String? + usageJson String? @map("usage_json") + + @@map("runs") +} + +/// Interrupt / approval records (`InterruptStore`). +model Interrupt { + interruptId String @id @map("interrupt_id") + runId String @map("run_id") + threadId String @map("thread_id") + status String + requestedAt BigInt @map("requested_at") + resolvedAt BigInt? @map("resolved_at") + payloadJson String @map("payload_json") + responseJson String? @map("response_json") + + @@map("interrupts") +} + +/// Scoped key/value metadata (`MetadataStore`). +model Metadata { + scope String + key String + valueJson String @map("value_json") + + @@id([scope, key]) + @@map("metadata") +} diff --git a/packages/ai-persistence-prisma/src/cli.ts b/packages/ai-persistence-prisma/src/cli.ts new file mode 100644 index 000000000..21273f6e3 --- /dev/null +++ b/packages/ai-persistence-prisma/src/cli.ts @@ -0,0 +1,10 @@ +#!/usr/bin/env node +import { runPrismaModelsCli } from './models-cli' + +try { + await runPrismaModelsCli(process.argv.slice(2)) +} catch (error) { + const message = error instanceof Error ? error.message : String(error) + process.stderr.write(`${message}\n`) + process.exitCode = 1 +} diff --git a/packages/ai-persistence-prisma/src/index.ts b/packages/ai-persistence-prisma/src/index.ts new file mode 100644 index 000000000..5aa909f9e --- /dev/null +++ b/packages/ai-persistence-prisma/src/index.ts @@ -0,0 +1,71 @@ +/** + * Prisma-backed state persistence for TanStack AI. + * + * Add the provider-neutral {@link prismaModels} fragment to a Prisma multi-file + * schema, run Prisma's normal migration workflow for your selected provider, + * then pass the generated client to {@link prismaPersistence}. + */ +import { resolveDelegates } from './model-contract' +import { + createInterruptStore, + createMessageStore, + createMetadataStore, + createRunStore, +} from './stores' +import type { PrismaModelMap } from './model-contract' + +export { prismaModels, prismaModelsFilename } from './models' +export { PrismaModelError } from './model-contract' +export type { PrismaModelMap } from './model-contract' + +/** + * Structural stand-in for a generated Prisma client. + * + * We deliberately do **not** import `PrismaClient` from `@prisma/client`: the + * stores only ever read model delegates off the client by name at runtime + * (see `resolveDelegates`), and the delegate query API (`findUnique`, `upsert`, + * `findMany`, `updateMany`, `deleteMany`) is identical across Prisma majors. + * Typing the parameter structurally keeps this package compatible with both the + * v6 `prisma-client-js` generator (client under `@prisma/client`) and the v7 + * `prisma-client` generator (client emitted to a custom `output` path and not + * exported from `@prisma/client`), so v7 users pass their generated client + * without a type mismatch. + */ +export type PrismaClientLike = object + +export interface PrismaPersistenceOptions { + /** + * Rename the TanStack AI models in your copy of the fragment — for example + * to avoid a collision with an existing `Message` or `Run` model — and map + * each store to the renamed client delegate here. Values are the camelCase + * client accessor names: `model ChatMessage` → `{ messages: 'chatMessage' }`. + * Keep the field names and types from the fragment; database table and + * column names are already yours via `@@map` / `@map`. + */ + models?: PrismaModelMap +} + +/** + * Wire TanStack AI persistence stores over a migrated Prisma client. + * + * No `locks` store is returned: this backend has no distributed lock primitive, + * and bundling an `InMemoryLockStore` would silently hand multi-instance + * deployments a lock that does not lock across instances. Consumers that need a + * lock (e.g. `withSandbox`) transparently fall back to an in-process + * `InMemoryLockStore`; for cross-instance locking use a distributed backend such + * as the Cloudflare Durable Object lock (`@tanstack/ai-persistence-cloudflare`). + */ +export function prismaPersistence( + prisma: PrismaClientLike, + options?: PrismaPersistenceOptions, +) { + const delegates = resolveDelegates(prisma, options?.models) + return { + stores: { + messages: createMessageStore(delegates), + runs: createRunStore(delegates), + interrupts: createInterruptStore(delegates), + metadata: createMetadataStore(delegates), + }, + } +} diff --git a/packages/ai-persistence-prisma/src/model-contract.ts b/packages/ai-persistence-prisma/src/model-contract.ts new file mode 100644 index 000000000..d2c9d618f --- /dev/null +++ b/packages/ai-persistence-prisma/src/model-contract.ts @@ -0,0 +1,217 @@ +/** + * Structural contract for the Prisma model delegates the stores operate over. + * + * `prismaPersistence` accepts a model-name map so applications can rename the + * TanStack AI models in their own copy of the fragment — for example to avoid + * collisions with an existing `Message` or `Run` model — and point the runtime + * at the renamed delegates (`prismaPersistence(prisma, { models })`). + * + * What stays fixed is the **client-level field surface**: field names + * (`threadId`, `messagesJson`, …), their types, and the default composite + * unique alias `scope_key` on the metadata model. Database table and column + * names are already yours via `@@map` / `@map` in the fragment, and extra + * app-owned fields are ignored by the stores (keep them optional or defaulted + * so creates succeed). + * + * The delegate interfaces below are the minimal structural slice of the + * generated client the stores call; the generated delegates satisfy them + * (asserted in the package's type tests). + */ + +export interface MessageRow { + threadId: string + messagesJson: string +} + +export interface RunRow { + runId: string + threadId: string + status: string + startedAt: bigint + finishedAt: bigint | null + error: string | null + usageJson: string | null +} + +export interface InterruptRow { + interruptId: string + runId: string + threadId: string + status: string + requestedAt: bigint + resolvedAt: bigint | null + payloadJson: string + responseJson: string | null +} + +export interface MetadataRow { + scope: string + key: string + valueJson: string +} + +export interface MessageDelegate { + findUnique: (args: { + where: { threadId: string } + }) => Promise + upsert: (args: { + where: { threadId: string } + create: { threadId: string; messagesJson: string } + update: { messagesJson: string } + }) => Promise +} + +export interface RunDelegate { + findUnique: (args: { where: { runId: string } }) => Promise + upsert: (args: { + where: { runId: string } + create: { + runId: string + threadId: string + status: string + startedAt: bigint + } + update: Record + }) => Promise + updateMany: (args: { + where: { runId: string } + data: { + status?: string + finishedAt?: bigint + error?: string + usageJson?: string + } + }) => Promise +} + +export interface InterruptDelegate { + findUnique: (args: { + where: { interruptId: string } + }) => Promise + findMany: (args: { + where: { threadId?: string; runId?: string; status?: string } + orderBy: { requestedAt: 'asc' } + }) => Promise> + upsert: (args: { + where: { interruptId: string } + create: { + interruptId: string + runId: string + threadId: string + status: string + requestedAt: bigint + payloadJson: string + responseJson: string | null + } + update: Record + }) => Promise + updateMany: (args: { + where: { interruptId: string } + data: { status: string; resolvedAt: bigint; responseJson?: string | null } + }) => Promise +} + +export interface MetadataDelegate { + findUnique: (args: { + where: { scope_key: { scope: string; key: string } } + }) => Promise + upsert: (args: { + where: { scope_key: { scope: string; key: string } } + create: { scope: string; key: string; valueJson: string } + update: { valueJson: string } + }) => Promise + deleteMany: (args: { + where: { scope: string; key: string } + }) => Promise +} + +/** The delegate set the stores operate over. */ +export interface TanstackAiDelegates { + message: MessageDelegate + run: RunDelegate + interrupt: InterruptDelegate + metadata: MetadataDelegate +} + +/** + * Store key → Prisma client delegate property. Values are the camelCase + * client accessor names (`prisma.`), not the PascalCase model names: + * a `model ChatMessage` is reached as `chatMessage`. + */ +export interface PrismaModelMap { + messages?: string + runs?: string + interrupts?: string + metadata?: string +} + +const defaultModelNames: Required = { + messages: 'message', + runs: 'run', + interrupts: 'interrupt', + metadata: 'metadata', +} + +const storeKeys = Object.keys(defaultModelNames) as Array< + keyof Required +> + +const delegateKeyByStoreKey: { + [K in keyof Required]: keyof TanstackAiDelegates +} = { + messages: 'message', + runs: 'run', + interrupts: 'interrupt', + metadata: 'metadata', +} + +/** A model map pointed `prismaPersistence` at missing or invalid delegates. */ +export class PrismaModelError extends Error { + constructor(problems: ReadonlyArray) { + super( + `Invalid TanStack AI Prisma model mapping:\n${problems + .map((problem) => ` - ${problem}`) + .join('\n')}`, + ) + this.name = 'PrismaModelError' + } +} + +function isDelegate(value: unknown): boolean { + return ( + value !== null && + typeof value === 'object' && + typeof Reflect.get(value, 'findUnique') === 'function' && + typeof Reflect.get(value, 'upsert') === 'function' + ) +} + +/** + * Resolve the four store delegates off the client, applying any model-name + * overrides and verifying each resolved delegate looks like a Prisma model + * delegate. Field shapes are enforced at compile time by the delegate + * interfaces and are not re-checked here. + */ +export function resolveDelegates( + client: object, + models?: PrismaModelMap, +): TanstackAiDelegates { + const problems: Array = [] + const resolved: Partial> = {} + for (const storeKey of storeKeys) { + const modelName = models?.[storeKey] ?? defaultModelNames[storeKey] + const candidate: unknown = Reflect.get(client, modelName) + if (!isDelegate(candidate)) { + problems.push( + `\`${storeKey}\` maps to \`client.${modelName}\`, which is not a Prisma model delegate.`, + ) + continue + } + resolved[delegateKeyByStoreKey[storeKey]] = candidate + } + if (problems.length > 0) throw new PrismaModelError(problems) + // Safe narrowing: presence and delegate shape were verified above; the + // per-method argument/result types are pinned by the delegate interfaces, + // which the generated client's delegates satisfy (asserted in type tests). + return resolved as TanstackAiDelegates +} diff --git a/packages/ai-persistence-prisma/src/models-cli.ts b/packages/ai-persistence-prisma/src/models-cli.ts new file mode 100644 index 000000000..42410dd8e --- /dev/null +++ b/packages/ai-persistence-prisma/src/models-cli.ts @@ -0,0 +1,121 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { prismaModels, prismaModelsFilename } from './models' + +export interface ModelsCliOutput { + writeStdout: (value: string) => void +} + +export class ModelsCliError extends Error { + constructor(message: string) { + super(message) + this.name = 'ModelsCliError' + } +} + +const usage = `Usage: tanstack-ai-prisma-models (--out | --stdout) [--force] + +Options: + --out Copy the models-only Prisma schema fragment. + --stdout Print the models-only Prisma schema fragment. + --force Replace a divergent fragment when copying. + --help Show this help. +` + +interface ParsedArguments { + force: boolean + help: boolean + outDirectory?: string + stdout: boolean +} + +function parseArguments(args: ReadonlyArray): ParsedArguments { + let force = false + let help = false + let outDirectory: string | undefined + let stdout = false + + for (let index = 0; index < args.length; index++) { + const argument = args[index] + if (argument === '--force') { + force = true + } else if (argument === '--help' || argument === '-h') { + help = true + } else if (argument === '--stdout') { + stdout = true + } else if (argument === '--out') { + const value = args[index + 1] + if (!value || value.startsWith('-')) { + throw new ModelsCliError('--out requires a directory.') + } + if (outDirectory !== undefined) { + throw new ModelsCliError('--out may only be provided once.') + } + outDirectory = value + index++ + } else { + throw new ModelsCliError(`Unknown argument: ${argument ?? ''}`) + } + } + + return { force, help, outDirectory, stdout } +} + +function isMissingFileError(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +async function readExistingFile(path: string): Promise { + try { + return await readFile(path, 'utf8') + } catch (error) { + if (isMissingFileError(error)) return undefined + throw error + } +} + +/** Execute the models asset CLI. Exported for deterministic CLI tests. */ +export async function runPrismaModelsCli( + args: ReadonlyArray, + output: ModelsCliOutput = { + writeStdout(value) { + process.stdout.write(value) + }, + }, +): Promise { + const parsed = parseArguments(args) + if (parsed.help) { + output.writeStdout(usage) + return + } + + const outputCount = + Number(parsed.stdout) + Number(parsed.outDirectory != null) + if (outputCount !== 1) { + throw new ModelsCliError( + 'Provide exactly one output mode: --out or --stdout.', + ) + } + if (parsed.stdout) { + if (parsed.force) { + throw new ModelsCliError('--force can only be used with --out.') + } + output.writeStdout(`${prismaModels.trimEnd()}\n`) + return + } + + const outDirectory = parsed.outDirectory + if (!outDirectory) { + throw new ModelsCliError('--out requires a directory.') + } + await mkdir(outDirectory, { recursive: true }) + const destination = join(outDirectory, prismaModelsFilename) + const existing = await readExistingFile(destination) + if (existing === prismaModels) return + if (existing !== undefined && !parsed.force) { + throw new ModelsCliError( + `Refusing to overwrite divergent Prisma models file: ${destination}. Re-run with --force to replace it.`, + ) + } + await writeFile(destination, prismaModels, 'utf8') +} diff --git a/packages/ai-persistence-prisma/src/models.ts b/packages/ai-persistence-prisma/src/models.ts new file mode 100644 index 000000000..6e22bb77b --- /dev/null +++ b/packages/ai-persistence-prisma/src/models.ts @@ -0,0 +1,7 @@ +import models from './assets/tanstack-ai.prisma?raw' + +/** Filename used by the Prisma models copy CLI. */ +export const prismaModelsFilename = 'tanstack-ai.prisma' + +/** Provider-neutral, models-only Prisma schema fragment. */ +export const prismaModels: string = models diff --git a/packages/ai-persistence-prisma/src/stores.ts b/packages/ai-persistence-prisma/src/stores.ts new file mode 100644 index 000000000..0089ae0eb --- /dev/null +++ b/packages/ai-persistence-prisma/src/stores.ts @@ -0,0 +1,237 @@ +/** + * AIPersistence store implementations over a Prisma `PrismaClient`. + * + * Each method mirrors the reference in-memory backend + * (`@tanstack/ai-persistence`'s `memory.ts`) and the sibling Drizzle backend + * (`@tanstack/ai-persistence-drizzle`'s `stores.ts`), including the + * insert-if-absent `InterruptStore.create` and `RunStore.createOrResume` + * semantics (`upsert` with an empty `update`). JSON-valued columns use + * provider-neutral Prisma `String` fields, so they are serialized with + * `JSON.stringify`/`JSON.parse` here. + */ +import type { + InterruptRow, + RunRow, + TanstackAiDelegates, +} from './model-contract' +import type { ModelMessage } from '@tanstack/ai' +import type { + InterruptRecord, + InterruptStore, + MessageStore, + MetadataStore, + RunRecord, + RunStatus, + RunStore, +} from '@tanstack/ai-persistence' + +export function createMessageStore({ + message, +}: TanstackAiDelegates): MessageStore { + return { + async loadThread(threadId) { + const row = await message.findUnique({ where: { threadId } }) + if (!row) return [] + return JSON.parse(row.messagesJson) as Array + }, + async saveThread(threadId, msgs: Array) { + const messagesJson = JSON.stringify(msgs) + await message.upsert({ + where: { threadId }, + create: { threadId, messagesJson }, + update: { messagesJson }, + }) + }, + } +} + +function mapRun(row: RunRow): RunRecord { + return { + runId: row.runId, + threadId: row.threadId, + status: row.status as RunStatus, + startedAt: Number(row.startedAt), + ...(row.finishedAt != null ? { finishedAt: Number(row.finishedAt) } : {}), + ...(row.error != null ? { error: row.error } : {}), + ...(row.usageJson != null + ? { usage: JSON.parse(row.usageJson) as RunRecord['usage'] } + : {}), + } +} + +export function createRunStore({ run }: TanstackAiDelegates): RunStore { + const store: RunStore = { + async createOrResume(input) { + const existing = await store.get(input.runId) + if (existing) return existing + const record: RunRecord = { + runId: input.runId, + threadId: input.threadId, + status: input.status ?? 'running', + startedAt: input.startedAt, + } + // Upsert with an empty update = insert-if-absent (never overwrites an + // existing run), matching the reference createOrResume semantics. + await run.upsert({ + where: { runId: record.runId }, + create: { + runId: record.runId, + threadId: record.threadId, + status: record.status, + startedAt: BigInt(record.startedAt), + }, + update: {}, + }) + return (await store.get(input.runId)) ?? record + }, + async update(runId, patch) { + const data: { + status?: RunStatus + finishedAt?: bigint + error?: string + usageJson?: string + } = {} + if (patch.status !== undefined) data.status = patch.status + if (patch.finishedAt !== undefined) + data.finishedAt = BigInt(patch.finishedAt) + if (patch.error !== undefined) data.error = patch.error + if (patch.usage !== undefined) + data.usageJson = JSON.stringify(patch.usage) + if (Object.keys(data).length === 0) return + // updateMany no-ops (does not throw) when the run does not exist. + await run.updateMany({ where: { runId }, data }) + }, + async get(runId) { + const row = await run.findUnique({ where: { runId } }) + return row ? mapRun(row) : null + }, + } + return store +} + +function mapInterrupt(row: InterruptRow): InterruptRecord { + return { + interruptId: row.interruptId, + runId: row.runId, + threadId: row.threadId, + status: row.status as InterruptRecord['status'], + requestedAt: Number(row.requestedAt), + ...(row.resolvedAt != null ? { resolvedAt: Number(row.resolvedAt) } : {}), + payload: JSON.parse(row.payloadJson) as Record, + ...(row.responseJson != null + ? { response: JSON.parse(row.responseJson) as unknown } + : {}), + } +} + +export function createInterruptStore({ + interrupt, +}: TanstackAiDelegates): InterruptStore { + return { + async create(record) { + await interrupt.upsert({ + where: { interruptId: record.interruptId }, + create: { + interruptId: record.interruptId, + runId: record.runId, + threadId: record.threadId, + status: 'pending', + requestedAt: BigInt(record.requestedAt), + payloadJson: JSON.stringify(record.payload), + responseJson: + record.response == null ? null : JSON.stringify(record.response), + }, + update: {}, + }) + }, + async resolve(interruptId, response) { + await interrupt.updateMany({ + where: { interruptId }, + data: { + status: 'resolved', + resolvedAt: BigInt(Date.now()), + responseJson: response == null ? null : JSON.stringify(response), + }, + }) + }, + async cancel(interruptId) { + await interrupt.updateMany({ + where: { interruptId }, + data: { status: 'cancelled', resolvedAt: BigInt(Date.now()) }, + }) + }, + async get(interruptId) { + const row = await interrupt.findUnique({ where: { interruptId } }) + return row ? mapInterrupt(row) : null + }, + async list(threadId) { + const rows = await interrupt.findMany({ + where: { threadId }, + orderBy: { requestedAt: 'asc' }, + }) + return rows.map(mapInterrupt) + }, + async listPending(threadId) { + const rows = await interrupt.findMany({ + where: { threadId, status: 'pending' }, + orderBy: { requestedAt: 'asc' }, + }) + return rows.map(mapInterrupt) + }, + async listByRun(runId) { + const rows = await interrupt.findMany({ + where: { runId }, + orderBy: { requestedAt: 'asc' }, + }) + return rows.map(mapInterrupt) + }, + async listPendingByRun(runId) { + const rows = await interrupt.findMany({ + where: { runId, status: 'pending' }, + orderBy: { requestedAt: 'asc' }, + }) + return rows.map(mapInterrupt) + }, + } +} + +function assertStorableMetadata(value: unknown): void { + if (value == null) { + throw new TypeError( + `TanStack AI metadata values must be defined, non-null JSON; received ${ + value === undefined ? '`undefined`' : '`null`' + }. Use \`delete(scope, key)\` to clear a value.`, + ) + } +} + +export function createMetadataStore({ + metadata, +}: TanstackAiDelegates): MetadataStore { + return { + async get(scope, key) { + const row = await metadata.findUnique({ + where: { scope_key: { scope, key } }, + }) + return row ? (JSON.parse(row.valueJson) as unknown) : null + }, + async set(scope, key, value) { + // SQL backends store JSON text in a NOT NULL column and cannot persist a + // nullish value: `JSON.stringify(undefined)` is `undefined` (no string), + // and the sibling Drizzle backend binds a JS `null` as SQL NULL — both + // violate NOT NULL. Reject nullish with a clear error (consistent across + // the SQL backends) instead of a cryptic driver failure. Unlike the + // in-memory reference, which round-trips nullish; use `delete` to clear. + assertStorableMetadata(value) + const valueJson = JSON.stringify(value) + await metadata.upsert({ + where: { scope_key: { scope, key } }, + create: { scope, key, valueJson }, + update: { valueJson }, + }) + }, + async delete(scope, key) { + await metadata.deleteMany({ where: { scope, key } }) + }, + } +} diff --git a/packages/ai-persistence-prisma/tests/api-types.test-d.ts b/packages/ai-persistence-prisma/tests/api-types.test-d.ts new file mode 100644 index 000000000..67e4fc687 --- /dev/null +++ b/packages/ai-persistence-prisma/tests/api-types.test-d.ts @@ -0,0 +1,38 @@ +import { expectTypeOf } from 'vitest' +import { PrismaClient } from '@prisma/client' +import type { + InterruptStore, + MessageStore, + MetadataStore, + RunStore, +} from '@tanstack/ai-persistence' +import { prismaPersistence } from '../src/index' +import type { + InterruptDelegate, + MessageDelegate, + MetadataDelegate, + RunDelegate, +} from '../src/model-contract' + +declare const prisma: PrismaClient +const persistence = prismaPersistence(prisma) + +// The generated client's delegates must satisfy the structural delegate +// contract the stores are written against — this is what makes renamed +// delegates from a user-generated client interchangeable with canonical ones. +expectTypeOf(prisma.message).toExtend() +expectTypeOf(prisma.run).toExtend() +expectTypeOf(prisma.interrupt).toExtend() +expectTypeOf(prisma.metadata).toExtend() + +const mapped = prismaPersistence(prisma, { + models: { messages: 'chatMessage' }, +}) +expectTypeOf(mapped.stores).toEqualTypeOf() + +expectTypeOf(persistence.stores.messages).toEqualTypeOf() +expectTypeOf(persistence.stores.runs).toEqualTypeOf() +expectTypeOf(persistence.stores.interrupts).toEqualTypeOf() +expectTypeOf(persistence.stores.metadata).toEqualTypeOf() +// No `locks` store: this backend has no distributed lock (see prismaPersistence). +expectTypeOf(persistence.stores).not.toHaveProperty('locks') diff --git a/packages/ai-persistence-prisma/tests/model-mapping.test.ts b/packages/ai-persistence-prisma/tests/model-mapping.test.ts new file mode 100644 index 000000000..573243251 --- /dev/null +++ b/packages/ai-persistence-prisma/tests/model-mapping.test.ts @@ -0,0 +1,137 @@ +import { rm } from 'node:fs/promises' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { afterAll, describe, expect, it } from 'vitest' +import { PrismaClient } from '@prisma/client' +import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' +import { PrismaModelError, prismaPersistence } from '../src/index' + +const clients: Array = [] +const temporaryDirectories: Array = [] + +const sqliteTestSchema = ` + CREATE TABLE messages ( + thread_id TEXT NOT NULL PRIMARY KEY, + messages_json TEXT NOT NULL + ); + CREATE TABLE runs ( + run_id TEXT NOT NULL PRIMARY KEY, + thread_id TEXT NOT NULL, + status TEXT NOT NULL, + started_at BIGINT NOT NULL, + finished_at BIGINT, + error TEXT, + usage_json TEXT + ); + CREATE TABLE interrupts ( + interrupt_id TEXT NOT NULL PRIMARY KEY, + run_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + status TEXT NOT NULL, + requested_at BIGINT NOT NULL, + resolved_at BIGINT, + payload_json TEXT NOT NULL, + response_json TEXT + ); + CREATE TABLE metadata ( + scope TEXT NOT NULL, + key TEXT NOT NULL, + value_json TEXT NOT NULL, + PRIMARY KEY (scope, key) + ); +` + +async function makeTestClient(): Promise { + const dir = mkdtempSync(join(tmpdir(), 'tanstack-ai-prisma-mapping-')) + temporaryDirectories.push(dir) + const dbPath = join(dir, 'state.db').replace(/\\/g, '/') + const database = new DatabaseSync(dbPath) + try { + database.exec(sqliteTestSchema) + } finally { + database.close() + } + const prisma = new PrismaClient({ + datasources: { db: { url: `file:${dbPath}` } }, + }) + clients.push(prisma) + return prisma +} + +/** + * A client whose TanStack AI model delegates live under renamed accessors, as + * generated from a fragment whose models were renamed to avoid collisions + * (e.g. `model ChatMessage`). Aliasing the canonical delegates is faithful: + * a delegate is bound to its model, not to the property it hangs off. + */ +async function makeRenamedClient(): Promise { + const prisma = await makeTestClient() + const renamed = { + chatMessage: prisma.message, + chatRun: prisma.run, + chatInterrupt: prisma.interrupt, + chatMetadata: prisma.metadata, + } + return renamed as unknown as PrismaClient +} + +const renamedModels = { + messages: 'chatMessage', + runs: 'chatRun', + interrupts: 'chatInterrupt', + metadata: 'chatMetadata', +} + +afterAll(async () => { + await Promise.all(clients.map((client) => client.$disconnect())) + await Promise.all( + temporaryDirectories.map((directory) => + rm(directory, { recursive: true, force: true }), + ), + ) +}) + +// The full store contract must hold when every model is reached through a +// renamed delegate. +runPersistenceConformance( + 'prisma (renamed models)', + async () => + prismaPersistence(await makeRenamedClient(), { models: renamedModels }), + // This backend has no distributed lock primitive. + { skip: ['locks'] }, +) + +describe('prismaPersistence model mapping', () => { + it('rejects a mapping that points at missing delegates', async () => { + const prisma = await makeTestClient() + expect(() => + prismaPersistence(prisma, { models: { messages: 'chatMessage' } }), + ).toThrow(PrismaModelError) + expect(() => + prismaPersistence(prisma, { models: { messages: 'chatMessage' } }), + ).toThrow(/`messages` maps to `client\.chatMessage`/) + }) + + it('lists every unresolved model in one error', async () => { + const prisma = await makeRenamedClient() + // Default names resolve nothing on a fully renamed client. + expect(() => prismaPersistence(prisma)).toThrow( + /messages[\s\S]*runs[\s\S]*interrupts[\s\S]*metadata/, + ) + }) + + it('supports partial maps over an otherwise canonical client', async () => { + const prisma = await makeTestClient() + const persistence = prismaPersistence(prisma, { + models: { messages: 'message' }, + }) + await persistence.stores.messages.saveThread('thread-1', [ + { role: 'user', content: 'hello' }, + ]) + expect( + await persistence.stores.messages.loadThread('thread-1'), + ).toHaveLength(1) + }) +}) diff --git a/packages/ai-persistence-prisma/tests/models-cli.test.ts b/packages/ai-persistence-prisma/tests/models-cli.test.ts new file mode 100644 index 000000000..36a997568 --- /dev/null +++ b/packages/ai-persistence-prisma/tests/models-cli.test.ts @@ -0,0 +1,59 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { prismaModels, prismaModelsFilename } from '../src/index' +import { runPrismaModelsCli } from '../src/models-cli' + +const temporaryDirectories: Array = [] + +async function createTemporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'tanstack-ai-prisma-cli-')) + temporaryDirectories.push(directory) + return directory +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ) +}) + +describe('Prisma models CLI', () => { + it('prints the models fragment to stdout', async () => { + let output = '' + await runPrismaModelsCli(['--stdout'], { + writeStdout(value) { + output += value + }, + }) + expect(output).toBe(`${prismaModels.trimEnd()}\n`) + }) + + it('copies the fragment without overwriting divergent files by default', async () => { + const directory = await createTemporaryDirectory() + await runPrismaModelsCli(['--out', directory]) + + const destination = join(directory, prismaModelsFilename) + expect(await readFile(destination, 'utf8')).toBe(prismaModels) + + await writeFile(destination, 'user-owned contents', 'utf8') + await expect(runPrismaModelsCli(['--out', directory])).rejects.toThrow( + /refusing to overwrite/i, + ) + expect(await readFile(destination, 'utf8')).toBe('user-owned contents') + + await runPrismaModelsCli(['--out', directory, '--force']) + expect(await readFile(destination, 'utf8')).toBe(prismaModels) + }) + + it('fails on ambiguous or incomplete output options', async () => { + const directory = await createTemporaryDirectory() + await expect(runPrismaModelsCli([])).rejects.toThrow(/--out.*--stdout/i) + await expect( + runPrismaModelsCli(['--stdout', '--out', directory]), + ).rejects.toThrow(/exactly one/i) + }) +}) diff --git a/packages/ai-persistence-prisma/tests/models.test.ts b/packages/ai-persistence-prisma/tests/models.test.ts new file mode 100644 index 000000000..5711faaeb --- /dev/null +++ b/packages/ai-persistence-prisma/tests/models.test.ts @@ -0,0 +1,66 @@ +import { execFileSync } from 'node:child_process' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { prismaModels, prismaModelsFilename } from '../src/index' + +const temporaryDirectories: Array = [] +const prismaCli = fileURLToPath( + new URL('../node_modules/prisma/build/index.js', import.meta.url), +) + +async function createTemporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'tanstack-ai-prisma-models-')) + temporaryDirectories.push(directory) + return directory +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ) +}) + +describe('Prisma models asset', () => { + it('is a models-only fragment with no provider or client generator', () => { + expect(prismaModelsFilename).toBe('tanstack-ai.prisma') + expect(prismaModels).toContain('model Message') + expect(prismaModels).toContain('model Metadata') + expect(prismaModels).not.toMatch(/\bgenerator\s+\w+\s*{/) + expect(prismaModels).not.toMatch(/\bdatasource\s+\w+\s*{/) + }) + + it('keeps the shipped models file equal to the embedded asset', async () => { + const shipped = await readFile( + fileURLToPath(new URL('../prisma/tanstack-ai.prisma', import.meta.url)), + 'utf8', + ) + expect(shipped).toBe(prismaModels) + }) + + it.each([ + ['sqlite', 'file:./state.db'], + ['postgresql', 'postgresql://user:password@localhost:5432/database'], + ['mysql', 'mysql://user:password@localhost:3306/database'], + ])('validates when composed into a %s schema', async (provider, url) => { + const directory = await createTemporaryDirectory() + const schemaPath = join(directory, 'schema.prisma') + await writeFile( + schemaPath, + `datasource db {\n provider = "${provider}"\n url = "${url}"\n}\n\n${prismaModels}`, + 'utf8', + ) + + expect(() => + execFileSync( + process.execPath, + [prismaCli, 'validate', '--schema', schemaPath], + { encoding: 'utf8', stdio: 'pipe' }, + ), + ).not.toThrow() + }) +}) diff --git a/packages/ai-persistence-prisma/tests/package-contract.test.ts b/packages/ai-persistence-prisma/tests/package-contract.test.ts new file mode 100644 index 000000000..a823747df --- /dev/null +++ b/packages/ai-persistence-prisma/tests/package-contract.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import packageJson from '../package.json' + +describe('Prisma package contract', () => { + it('publishes the adapter, models CLI, and provider-neutral models asset', () => { + expect(packageJson.exports).toEqual({ + '.': { + types: './dist/esm/index.d.ts', + import: './dist/esm/index.js', + }, + }) + expect(packageJson.bin).toEqual({ + 'tanstack-ai-prisma-models': './bin/tanstack-ai-prisma-models.mjs', + }) + expect(packageJson.files).toEqual( + expect.arrayContaining([ + 'bin', + 'dist', + 'src', + 'prisma/tanstack-ai.prisma', + ]), + ) + }) + + it('requires a Prisma client with generally available multi-file schemas', () => { + expect(packageJson.peerDependencies['@prisma/client']).toBe('>=6.7.0') + }) +}) diff --git a/packages/ai-persistence-prisma/tests/prisma.conformance.test.ts b/packages/ai-persistence-prisma/tests/prisma.conformance.test.ts new file mode 100644 index 000000000..69c029199 --- /dev/null +++ b/packages/ai-persistence-prisma/tests/prisma.conformance.test.ts @@ -0,0 +1,82 @@ +import { rm } from 'node:fs/promises' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { afterAll } from 'vitest' +import { PrismaClient } from '@prisma/client' +import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' +import { prismaPersistence } from '../src/index' + +const clients: Array = [] +const temporaryDirectories: Array = [] + +const sqliteTestSchema = ` + CREATE TABLE messages ( + thread_id TEXT NOT NULL PRIMARY KEY, + messages_json TEXT NOT NULL + ); + CREATE TABLE runs ( + run_id TEXT NOT NULL PRIMARY KEY, + thread_id TEXT NOT NULL, + status TEXT NOT NULL, + started_at BIGINT NOT NULL, + finished_at BIGINT, + error TEXT, + usage_json TEXT + ); + CREATE TABLE interrupts ( + interrupt_id TEXT NOT NULL PRIMARY KEY, + run_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + status TEXT NOT NULL, + requested_at BIGINT NOT NULL, + resolved_at BIGINT, + payload_json TEXT NOT NULL, + response_json TEXT + ); + CREATE TABLE metadata ( + scope TEXT NOT NULL, + key TEXT NOT NULL, + value_json TEXT NOT NULL, + PRIMARY KEY (scope, key) + ); +` + +function initializeSqliteTestDatabase(path: string): void { + const database = new DatabaseSync(path) + try { + database.exec(sqliteTestSchema) + } finally { + database.close() + } +} + +/** Create a PrismaClient over a fresh initialized temporary SQLite database. */ +async function makeTestClient(): Promise { + const dir = mkdtempSync(join(tmpdir(), 'tanstack-ai-prisma-')) + temporaryDirectories.push(dir) + const dbPath = join(dir, 'state.db').replace(/\\/g, '/') + initializeSqliteTestDatabase(dbPath) + const prisma = new PrismaClient({ + datasources: { db: { url: `file:${dbPath}` } }, + }) + clients.push(prisma) + return prisma +} + +afterAll(async () => { + await Promise.all(clients.map((client) => client.$disconnect())) + await Promise.all( + temporaryDirectories.map((directory) => + rm(directory, { recursive: true, force: true }), + ), + ) +}) + +runPersistenceConformance( + 'prisma', + async () => prismaPersistence(await makeTestClient()), + // This backend has no distributed lock primitive. + { skip: ['locks'] }, +) diff --git a/packages/ai-persistence-prisma/tests/store-behavior.test.ts b/packages/ai-persistence-prisma/tests/store-behavior.test.ts new file mode 100644 index 000000000..f1b862b74 --- /dev/null +++ b/packages/ai-persistence-prisma/tests/store-behavior.test.ts @@ -0,0 +1,72 @@ +import { rm } from 'node:fs/promises' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { afterAll, describe, expect, it } from 'vitest' +import { PrismaClient } from '@prisma/client' +import { prismaPersistence } from '../src/index' + +const clients: Array = [] +const directories: Array = [] + +// Only the tables these tests query need to exist; the generated client carries +// all delegates regardless of which tables the database has. +const schema = ` + CREATE TABLE metadata ( + scope TEXT NOT NULL, + key TEXT NOT NULL, + value_json TEXT NOT NULL, + PRIMARY KEY (scope, key) + ); +` + +function makePersistence(): ReturnType { + const dir = mkdtempSync(join(tmpdir(), 'tanstack-ai-prisma-behavior-')) + directories.push(dir) + const dbPath = join(dir, 'state.db').replace(/\\/g, '/') + const database = new DatabaseSync(dbPath) + try { + database.exec(schema) + } finally { + database.close() + } + const prisma = new PrismaClient({ + datasources: { db: { url: `file:${dbPath}` } }, + }) + clients.push(prisma) + return prismaPersistence(prisma) +} + +afterAll(async () => { + await Promise.all(clients.map((client) => client.$disconnect())) + await Promise.all( + directories.map((directory) => + rm(directory, { recursive: true, force: true }), + ), + ) +}) + +describe('prisma metadata nullish values', () => { + it('rejects nullish values with a clear error instead of a NOT NULL crash', async () => { + const { stores } = makePersistence() + + // A NOT NULL column cannot store nullish; surface a clear, actionable error + // (consistent with the Drizzle backend) rather than a driver crash. + await expect(stores.metadata.set('scope', 'u', undefined)).rejects.toThrow( + /metadata values must be defined/, + ) + await expect(stores.metadata.set('scope', 'n', null)).rejects.toThrow( + /metadata values must be defined/, + ) + expect(await stores.metadata.get('scope', 'u')).toBeNull() + + // Falsy-but-defined values are stored and round-trip. + await stores.metadata.set('scope', 'zero', 0) + expect(await stores.metadata.get('scope', 'zero')).toBe(0) + await stores.metadata.set('scope', 'empty', '') + expect(await stores.metadata.get('scope', 'empty')).toBe('') + await stores.metadata.set('scope', 'obj', { a: 1 }) + expect(await stores.metadata.get('scope', 'obj')).toEqual({ a: 1 }) + }) +}) diff --git a/packages/ai-persistence-prisma/tsconfig.json b/packages/ai-persistence-prisma/tsconfig.json new file mode 100644 index 000000000..c38689f4e --- /dev/null +++ b/packages/ai-persistence-prisma/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ai-persistence-prisma/vite.config.ts b/packages/ai-persistence-prisma/vite.config.ts new file mode 100644 index 000000000..f56314fed --- /dev/null +++ b/packages/ai-persistence-prisma/vite.config.ts @@ -0,0 +1,37 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + '**/types.ts', + ], + include: ['src/**/*.ts'], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts', './src/cli.ts'], + srcDir: './src', + cjs: false, + }), +) diff --git a/packages/ai-persistence/package.json b/packages/ai-persistence/package.json new file mode 100644 index 000000000..05f20cd0f --- /dev/null +++ b/packages/ai-persistence/package.json @@ -0,0 +1,63 @@ +{ + "name": "@tanstack/ai-persistence", + "version": "0.0.0", + "description": "Composable state persistence for TanStack AI messages, runs, interrupts, metadata, and locks.", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-persistence" + }, + "keywords": [ + "ai", + "ai-sdk", + "typescript", + "tanstack", + "persistence", + "durable", + "resume", + "chat-history" + ], + "type": "module", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + }, + "./testkit": { + "types": "./dist/esm/testkit/conformance.d.ts", + "import": "./dist/esm/testkit/conformance.js" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "vite build", + "clean": "premove ./build ./dist", + "lint:fix": "oxlint src --type-aware --fix", + "test:build": "publint --strict", + "test:lib": "vitest", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc", + "test:oxlint": "oxlint src --type-aware" + }, + "peerDependencies": { + "@tanstack/ai": "workspace:^", + "vitest": "^4.1.10" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + }, + "devDependencies": { + "@tanstack/ai": "workspace:*", + "@vitest/coverage-v8": "4.0.14", + "vitest": "^4.1.10" + } +} diff --git a/packages/ai-persistence/src/capabilities.ts b/packages/ai-persistence/src/capabilities.ts new file mode 100644 index 000000000..06de59099 --- /dev/null +++ b/packages/ai-persistence/src/capabilities.ts @@ -0,0 +1,24 @@ +/** + * Persistence capability tokens. + * + * `withPersistence` PROVIDES these so later middleware (and harness adapters) + * can read durable state. `LocksCapability` is defined in `./locks` (see the note + * there): sandbox persistence, the only cross-package consumer, is deferred, so + * the token lives local to this package for now. + */ +import { createCapability } from '@tanstack/ai' +import type { AIPersistence, InterruptStore } from './types' + +export const PersistenceCapability = + createCapability()('persistence') + +export const InterruptsCapability = createCapability()( + 'persistence.interrupts', +) + +export const [getPersistence, providePersistence] = PersistenceCapability +export const [getInterrupts, provideInterrupts] = InterruptsCapability + +// Locks token lives in this package (see ./locks); re-export so consumers import +// everything persistence-related from here. +export { LocksCapability, getLocks, provideLocks } from './locks' diff --git a/packages/ai-persistence/src/index.ts b/packages/ai-persistence/src/index.ts new file mode 100644 index 000000000..8b2f1226a --- /dev/null +++ b/packages/ai-persistence/src/index.ts @@ -0,0 +1,47 @@ +// Store contracts + aggregate +export { composePersistence, defineAIPersistence } from './types' +export type { + MessageStore, + RunStatus, + RunRecord, + RunStore, + InterruptRecord, + InterruptStatus, + InterruptStore, + MetadataStore, + AIPersistence, + AIPersistenceStores, + AIPersistenceOverrides, + ComposedAIPersistenceStores, +} from './types' + +// Middleware +export { withPersistence, withGenerationPersistence } from './middleware' + +// Server helper: rehydrate a thread's messages for a client load +export { reconstructChat } from './reconstruct' +export type { ReconstructChatOptions } from './reconstruct' + +// Reference in-memory implementation +export { memoryPersistence } from './memory' + +// Interrupt controller +export { createInterruptController } from './interrupts' +export type { InterruptController } from './interrupts' + +// Capabilities (incl. the Locks token, owned by this package) +export { + PersistenceCapability, + InterruptsCapability, + getPersistence, + providePersistence, + getInterrupts, + provideInterrupts, + LocksCapability, + getLocks, + provideLocks, +} from './capabilities' + +// Lock primitive (owned here; sandbox persistence will bridge the token later) +export { InMemoryLockStore } from './locks' +export type { LockStore } from './locks' diff --git a/packages/ai-persistence/src/interrupts.ts b/packages/ai-persistence/src/interrupts.ts new file mode 100644 index 000000000..f8cc1d329 --- /dev/null +++ b/packages/ai-persistence/src/interrupts.ts @@ -0,0 +1,24 @@ +import type { InterruptRecord, InterruptStore } from './types' + +export interface InterruptController { + resolve: (interruptId: string, response?: unknown) => Promise + cancel: (interruptId: string) => Promise + request: ( + record: Omit, + ) => Promise + listPending: (threadId: string) => Promise> + listPendingByRun: (runId: string) => Promise> +} + +export function createInterruptController(opts: { + store: InterruptStore +}): InterruptController { + const { store } = opts + return { + resolve: (interruptId, response) => store.resolve(interruptId, response), + cancel: (interruptId) => store.cancel(interruptId), + request: (record) => store.create(record), + listPending: (threadId) => store.listPending(threadId), + listPendingByRun: (runId) => store.listPendingByRun(runId), + } +} diff --git a/packages/ai-persistence/src/locks.ts b/packages/ai-persistence/src/locks.ts new file mode 100644 index 000000000..d7d4907c6 --- /dev/null +++ b/packages/ai-persistence/src/locks.ts @@ -0,0 +1,60 @@ +/** + * Distributed-mutex primitive for the persistence layer. + * + * ponytail: the `'locks'` capability token is defined LOCALLY here. Capability + * identity is by object reference (see `createCapability`), not by name, so this + * token does NOT interoperate with the identically-named `LocksCapability` that + * `@tanstack/ai-sandbox` owns. That is fine for this batch: the only consumer of + * a persistence-provided lock store is `withSandbox`, and sandbox persistence is + * deferred. When it lands, share ONE handle between provider and consumer (the + * neutral home is core `@tanstack/ai`); until then this token has no cross-package + * consumer and just keeps the persistence API self-contained. + */ +import { createCapability } from '@tanstack/ai' + +/** + * Mutual exclusion around a critical section keyed by `key`. A cloudflare + * Durable Object store is the only backend here that is safe across instances; + * the in-memory default is correct within a single process only. Lease-backed + * implementations abort `signal` as soon as ownership can no longer be + * guaranteed; the callback must stop externally visible mutations when it aborts. + */ +export interface LockStore { + withLock: ( + key: string, + fn: (signal: AbortSignal) => Promise, + ) => Promise +} + +/** The lock capability. Provided by `withPersistence` when a `locks` store is present. */ +export const LocksCapability = createCapability()('locks') + +/** Destructured accessors: `getLocks(ctx)` / `provideLocks(ctx, store)`. */ +export const [getLocks, provideLocks] = LocksCapability + +/** + * In-memory {@link LockStore} — a per-key promise chain. Correct within a single + * process; multi-instance correctness needs a distributed lock from the backend. + */ +export class InMemoryLockStore implements LockStore { + private readonly chains = new Map>() + + withLock( + key: string, + fn: (signal: AbortSignal) => Promise, + ): Promise { + const prior = this.chains.get(key) ?? Promise.resolve() + const runCriticalSection = () => fn(new AbortController().signal) + // Chain after the prior holder regardless of how it settled. + const run = prior.then(runCriticalSection, runCriticalSection) + // Keep the chain alive but swallow rejections so one failure doesn't poison the lock. + this.chains.set( + key, + run.then( + () => undefined, + () => undefined, + ), + ) + return run + } +} diff --git a/packages/ai-persistence/src/memory.ts b/packages/ai-persistence/src/memory.ts new file mode 100644 index 000000000..da9d0d80c --- /dev/null +++ b/packages/ai-persistence/src/memory.ts @@ -0,0 +1,165 @@ +import { InMemoryLockStore } from './locks' +import { defineAIPersistence } from './types' +import type { LockStore } from './locks' +import type { ModelMessage } from '@tanstack/ai' +import type { + InterruptRecord, + InterruptStore, + MessageStore, + MetadataStore, + RunRecord, + RunStore, +} from './types' + +class MemoryMessageStore implements MessageStore { + private readonly threads = new Map>() + loadThread(threadId: string): Promise> { + return Promise.resolve(this.threads.get(threadId)?.slice() ?? []) + } + saveThread(threadId: string, messages: Array): Promise { + this.threads.set(threadId, messages.slice()) + return Promise.resolve() + } +} + +class MemoryRunStore implements RunStore { + private readonly runs = new Map() + createOrResume(input: { + runId: string + threadId: string + status?: RunRecord['status'] + startedAt: number + }): Promise { + const existing = this.runs.get(input.runId) + if (existing) return Promise.resolve(existing) + const record: RunRecord = { + runId: input.runId, + threadId: input.threadId, + status: input.status ?? 'running', + startedAt: input.startedAt, + } + this.runs.set(record.runId, record) + return Promise.resolve(record) + } + update( + runId: string, + patch: Partial< + Pick + >, + ): Promise { + const existing = this.runs.get(runId) + if (existing) this.runs.set(runId, { ...existing, ...patch }) + return Promise.resolve() + } + get(runId: string): Promise { + return Promise.resolve(this.runs.get(runId) ?? null) + } +} + +class MemoryInterruptStore implements InterruptStore { + private readonly interrupts = new Map() + create( + record: Omit, + ): Promise { + // Insert-if-absent (canonical semantics, matching the SQL backends' + // ON CONFLICT DO NOTHING): a duplicate id must never clobber an existing — + // possibly already resolved — interrupt back to pending. + if (!this.interrupts.has(record.interruptId)) { + this.interrupts.set(record.interruptId, { ...record, status: 'pending' }) + } + return Promise.resolve() + } + resolve(interruptId: string, response?: unknown): Promise { + const existing = this.interrupts.get(interruptId) + if (existing) { + this.interrupts.set(interruptId, { + ...existing, + status: 'resolved', + resolvedAt: Date.now(), + response, + }) + } + return Promise.resolve() + } + cancel(interruptId: string): Promise { + const existing = this.interrupts.get(interruptId) + if (existing) { + this.interrupts.set(interruptId, { + ...existing, + status: 'cancelled', + resolvedAt: Date.now(), + }) + } + return Promise.resolve() + } + get(interruptId: string): Promise { + return Promise.resolve(this.interrupts.get(interruptId) ?? null) + } + list(threadId: string): Promise> { + return Promise.resolve( + [...this.interrupts.values()].filter( + (interrupt) => interrupt.threadId === threadId, + ), + ) + } + listPending(threadId: string): Promise> { + return Promise.resolve( + [...this.interrupts.values()].filter( + (interrupt) => + interrupt.threadId === threadId && interrupt.status === 'pending', + ), + ) + } + listByRun(runId: string): Promise> { + return Promise.resolve( + [...this.interrupts.values()].filter( + (interrupt) => interrupt.runId === runId, + ), + ) + } + listPendingByRun(runId: string): Promise> { + return Promise.resolve( + [...this.interrupts.values()].filter( + (interrupt) => + interrupt.runId === runId && interrupt.status === 'pending', + ), + ) + } +} + +class MemoryMetadataStore implements MetadataStore { + private readonly values = new Map() + get(scope: string, key: string): Promise { + const storageKey = `${scope}:${key}` + return Promise.resolve( + this.values.has(storageKey) ? this.values.get(storageKey) : null, + ) + } + set(scope: string, key: string, value: unknown): Promise { + this.values.set(`${scope}:${key}`, value) + return Promise.resolve() + } + delete(scope: string, key: string): Promise { + this.values.delete(`${scope}:${key}`) + return Promise.resolve() + } +} + +interface MemoryPersistenceStores { + messages: MessageStore + runs: RunStore + interrupts: InterruptStore + metadata: MetadataStore + locks: LockStore +} + +export function memoryPersistence() { + const stores: MemoryPersistenceStores = { + messages: new MemoryMessageStore(), + runs: new MemoryRunStore(), + interrupts: new MemoryInterruptStore(), + metadata: new MemoryMetadataStore(), + locks: new InMemoryLockStore(), + } + return defineAIPersistence({ stores }) +} diff --git a/packages/ai-persistence/src/middleware.ts b/packages/ai-persistence/src/middleware.ts new file mode 100644 index 000000000..77866b589 --- /dev/null +++ b/packages/ai-persistence/src/middleware.ts @@ -0,0 +1,651 @@ +import { defineChatMiddleware } from '@tanstack/ai' +import { + InterruptsCapability, + LocksCapability, + PersistenceCapability, + provideInterrupts, + provideLocks, + providePersistence, +} from './capabilities' +import { + validateChatPersistenceStores, + validatePersistenceStoreKeys, +} from './types' +import type { + AbortInfo, + ChatMiddleware, + ChatMiddlewareConfig, + ChatMiddlewareContext, + ChatResumeToolState, + ErrorInfo, + FinishInfo, + GenerationAbortInfo, + GenerationErrorInfo, + GenerationFinishInfo, + GenerationMiddleware, + GenerationMiddlewareContext, + ModelMessage, + RunAgentResumeItem, + StreamChunk, + ToolApprovalResolution, + TokenUsage, +} from '@tanstack/ai' +import type { + AIPersistence, + AIPersistenceStores, + InterruptRecord, + RunStore, +} from './types' + +interface RunStateEntry { + merged: boolean + interrupted: boolean + /** + * Resumes accepted in `onConfig` but not yet committed to the interrupt + * store. They are applied (resolve/cancel) only once the run reaches a + * successful boundary — see {@link commitPendingResumes}. Left uncommitted + * (still pending in the store) if the run fails or aborts first. + */ + pendingResumes?: { + pending: Array + resumeByInterruptId: Map + } + /** Accumulated terminal-turn text, for throttled streaming snapshots (B). */ + streamingText?: string + /** Epoch ms of the last streaming snapshot, to throttle writes (B). */ + lastSnapshotAt?: number + /** + * The current assistant turn's stream messageId, captured from + * `TEXT_MESSAGE_START`. Persisted onto the assistant message so its identity + * survives the persist → hydrate round-trip and a reload can resume the same + * bubble in place. + */ + streamingMessageId?: string +} + +const runState = new WeakMap() + +const validResumeStatuses = new Set(['resolved', 'cancelled']) + +function validatePendingResumes( + pending: Array, + resume: Array | undefined, +): Map { + const pendingInterruptIds = new Set( + pending.map((interrupt) => interrupt.interruptId), + ) + const resumeByInterruptId = new Map( + (resume ?? []).map((entry) => [entry.interruptId, entry]), + ) + if (pending.length === 0) { + const staleEntry = resume?.[0] + if (staleEntry) { + throw new Error( + `Resume entry references non-pending interrupt ${staleEntry.interruptId}.`, + ) + } + return resumeByInterruptId + } + if (!resume || resume.length === 0) { + throw new Error( + `Thread has pending interrupts; resume is required before accepting new input.`, + ) + } + + for (const interrupt of pending) { + const entry = resumeByInterruptId.get(interrupt.interruptId) + if (!entry) { + throw new Error( + `Missing resume entry for pending interrupt ${interrupt.interruptId}.`, + ) + } + if (!validResumeStatuses.has(entry.status)) { + throw new Error( + `Invalid resume status for pending interrupt ${interrupt.interruptId}: ${entry.status}.`, + ) + } + } + for (const entry of resume) { + if (!pendingInterruptIds.has(entry.interruptId)) { + throw new Error( + `Resume entry references non-pending interrupt ${entry.interruptId}.`, + ) + } + } + return resumeByInterruptId +} + +async function applyPendingResumes( + pending: Array, + resumeByInterruptId: Map, + interrupts: NonNullable, +): Promise { + for (const interrupt of pending) { + const entry = resumeByInterruptId.get(interrupt.interruptId) + if (!entry) continue + if (entry.status === 'resolved') { + await interrupts.resolve(interrupt.interruptId, entry.payload) + } else { + await interrupts.cancel(interrupt.interruptId) + } + } +} + +/** + * Commit the resumes stashed in `onConfig`, marking each resumed interrupt + * resolved/cancelled. Called only from success boundaries (`onFinish`, and the + * `onChunk` interrupt boundary) so a provider failure or abort between accepting + * the resume and reaching a boundary leaves the interrupts pending — the + * approval is not consumed and a retry with the same resume succeeds. Idempotent + * and a no-op when nothing is stashed. + */ +async function commitPendingResumes( + state: RunStateEntry | undefined, + interrupts: AIPersistence['stores']['interrupts'], +): Promise { + if (!state?.pendingResumes || !interrupts) return + const { pending, resumeByInterruptId } = state.pendingResumes + state.pendingResumes = undefined + await applyPendingResumes(pending, resumeByInterruptId, interrupts) +} + +function objectValue(value: unknown): Record | null { + return value && typeof value === 'object' + ? (value as Record) + : null +} + +function stringField( + value: Record, + key: string, +): string | undefined { + return typeof value[key] === 'string' ? value[key] : undefined +} + +function interruptKind(interrupt: InterruptRecord): string | undefined { + const metadata = objectValue(interrupt.payload.metadata) + return metadata ? stringField(metadata, 'kind') : undefined +} + +function resolvedApprovalDecision(entry: RunAgentResumeItem): boolean { + if (entry.status === 'cancelled') return false + const payload = objectValue(entry.payload) + // Fail closed: persisted resume payloads may be malformed or truncated, so a + // missing/non-boolean `approved` denies the tool rather than running it. + return typeof payload?.approved === 'boolean' ? payload.approved : false +} + +/** + * Translate the persisted pending interrupts + the resume batch into the + * `ChatResumeToolState` the chat engine consumes. This is the server-authoritative + * counterpart to the engine's ephemeral (client-history) reconstruction: because + * the persistence flow sends empty client messages, the engine has no history to + * rebuild from, so persistence supplies the resume state directly (and clears + * `config.resume` so the ephemeral path is skipped — see `onConfig`). + */ +function resumeToolStateFromPending( + pending: Array, + resumeByInterruptId: Map, +): ChatResumeToolState | undefined { + const approvals = new Map() + const clientToolResults = new Map() + + for (const interrupt of pending) { + const entry = resumeByInterruptId.get(interrupt.interruptId) + if (!entry) continue + + const kind = interruptKind(interrupt) + const reason = stringField(interrupt.payload, 'reason') + const toolCallId = stringField(interrupt.payload, 'toolCallId') + + if (kind === 'approval' || reason === 'approval_required') { + approvals.set(interrupt.interruptId, resolvedApprovalDecision(entry)) + continue + } + + if ( + entry.status === 'resolved' && + toolCallId && + (kind === 'client_tool' || reason === 'client_tool_input') + ) { + clientToolResults.set(toolCallId, entry.payload) + } + } + + if (approvals.size === 0 && clientToolResults.size === 0) return undefined + return { approvals, clientToolResults } +} + +/** + * Build the transcript to persist when a run finishes successfully. + * + * The chat engine appends an assistant message to the middleware message list + * only when that turn carries tool calls (to feed the agent loop); a run's + * terminal *text* reply is never appended. So `ctx.messages` at `onFinish` is + * missing the assistant's final answer. Reattach it from the finish info — + * `info.content` is the last turn's accumulated text (reset each cycle) — so + * the stored thread is the complete conversation a server-authoritative client + * hydrates on load. A guard avoids duplicating a terminal assistant turn should + * the engine ever start appending it itself. + */ +function finishedTranscript( + messages: ReadonlyArray, + info: FinishInfo, + messageId: string | undefined, +): Array { + const transcript = [...messages] + const last = transcript[transcript.length - 1] + const alreadyPresent = + last?.role === 'assistant' && + last.toolCalls === undefined && + last.content === info.content + if (info.content && !alreadyPresent) { + // Stamp the terminal turn with its stream messageId so a hydrated bubble + // keeps the same identity as the live stream (in-place resume on reload). + transcript.push({ + role: 'assistant', + content: info.content, + ...(messageId ? { id: messageId } : {}), + }) + } + return transcript +} + +function interruptPayload(interrupt: unknown): Record { + return interrupt && typeof interrupt === 'object' + ? { ...(interrupt as Record) } + : { value: interrupt } +} + +// --------------------------------------------------------------------------- +// Shared store / feature plan +// --------------------------------------------------------------------------- + +interface PersistencePlan { + wantsMessages: boolean + wantsInterrupts: boolean + wantsLocks: boolean + runs: AIPersistence['stores']['runs'] +} + +function resolvePersistencePlan(persistence: AIPersistence): PersistencePlan { + return { + wantsMessages: persistence.stores.messages !== undefined, + wantsInterrupts: persistence.stores.interrupts !== undefined, + wantsLocks: persistence.stores.locks !== undefined, + runs: persistence.stores.runs, + } +} + +type StoreIsDefinitelyPresent< + TStores extends AIPersistenceStores, + TKey extends keyof AIPersistenceStores, +> = TKey extends keyof TStores + ? object extends Pick + ? false + : [Exclude] extends [never] + ? false + : true + : false + +type StoreIsDefinitelyAbsent< + TStores extends AIPersistenceStores, + TKey extends keyof AIPersistenceStores, +> = TKey extends keyof TStores + ? [Exclude] extends [never] + ? true + : false + : true + +type InvalidChatPersistence = + StoreIsDefinitelyPresent extends true + ? StoreIsDefinitelyAbsent + : false + +type ValidChatPersistence = + InvalidChatPersistence extends true ? never : unknown + +async function createOrResumeRun( + runs: RunStore | undefined, + runId: string, + threadId: string, +): Promise { + await runs?.createOrResume({ + runId, + threadId, + startedAt: Date.now(), + }) +} + +async function completeRun( + runs: RunStore | undefined, + runId: string, + usage?: TokenUsage, +): Promise { + await runs?.update(runId, { + status: 'completed', + finishedAt: Date.now(), + ...(usage ? { usage } : {}), + }) +} + +async function failRun( + runs: RunStore | undefined, + runId: string, + error: unknown, +): Promise { + await runs?.update(runId, { + status: 'failed', + finishedAt: Date.now(), + error: error instanceof Error ? error.message : String(error), + }) +} + +async function interruptRun( + runs: RunStore | undefined, + runId: string, +): Promise { + await runs?.update(runId, { + status: 'interrupted', + finishedAt: Date.now(), + }) +} + +// --------------------------------------------------------------------------- +// Chat middleware +// --------------------------------------------------------------------------- + +/** + * Chat-only persistence middleware. Provides durable **state** for `chat()`: + * thread messages, run records, interrupts, and locks. This middleware never + * mutates the chunk stream; delivery durability (replaying a + * disconnected/reloaded stream) is a separate transport-layer concern tracked + * in PR #955. + * + * ⚠️ AUTHORITATIVE-HISTORY CONTRACT: when a request carries a non-empty + * `messages` array it is treated as the FULL conversation history and, on + * finish, **overwrites** the entire stored thread. Post only the complete + * transcript, never a delta — sending just the newest message(s) will replace + * (and thereby destroy) the stored thread. To continue a stored thread without + * resending history, pass an empty `messages` array and the stored transcript + * is loaded and used. + */ +export interface WithPersistenceOptions { + /** + * Also persist a throttled snapshot of the in-progress assistant reply while + * it streams. Off by default — the transcript is otherwise persisted at the + * pending turn (`onStart`), interrupt boundaries, and completion (`onFinish`). + * Enable it to recover partial output if the process dies mid-generation, at + * the cost of extra writes. Snapshots are throttled to at most one per + * {@link WithPersistenceOptions.snapshotIntervalMs}. + */ + snapshotStreaming?: boolean + /** + * Minimum milliseconds between streaming snapshots when `snapshotStreaming` + * is on. Defaults to 1000. + */ + snapshotIntervalMs?: number +} + +export function withPersistence( + persistence: AIPersistence & ValidChatPersistence, + options?: WithPersistenceOptions, +): ChatMiddleware +export function withPersistence( + persistence: AIPersistence, + options: WithPersistenceOptions = {}, +): ChatMiddleware { + validateChatPersistenceStores(persistence) + const snapshotStreaming = options.snapshotStreaming ?? false + const snapshotIntervalMs = options.snapshotIntervalMs ?? 1000 + const plan = resolvePersistencePlan(persistence) + const { wantsMessages, wantsInterrupts, wantsLocks, runs } = plan + + const provides = [ + PersistenceCapability, + ...(wantsInterrupts ? [InterruptsCapability] : []), + ...(wantsLocks ? [LocksCapability] : []), + ] + + return defineChatMiddleware({ + name: 'chat-persistence', + provides, + setup(ctx: ChatMiddlewareContext) { + providePersistence(ctx, persistence) + + runState.set(ctx, { + merged: false, + interrupted: false, + }) + + if (wantsInterrupts && persistence.stores.interrupts) { + provideInterrupts(ctx, persistence.stores.interrupts) + } + if (wantsLocks && persistence.stores.locks) { + provideLocks(ctx, persistence.stores.locks) + } + }, + + async onConfig(ctx: ChatMiddlewareContext, config: ChatMiddlewareConfig) { + if (ctx.phase !== 'init') return + + const patch: Partial = {} + + if (wantsInterrupts && persistence.stores.interrupts) { + const pending = await persistence.stores.interrupts.listPending( + ctx.threadId, + ) + // Gate: a thread with pending interrupts must carry a resume batch that + // references them. + const resumeByInterruptId = validatePendingResumes( + pending, + config.resume, + ) + // Persistence is the server-authoritative resume path: translate the + // persisted interrupts into the engine's resume tool state and CLEAR + // `config.resume`, so the engine skips its ephemeral reconstruction + // (which needs a parentRunId and the client message history the + // persistence flow deliberately omits). + if ((config.resume?.length ?? 0) > 0) { + const resumeToolState = resumeToolStateFromPending( + pending, + resumeByInterruptId, + ) + patch.resume = [] + if (resumeToolState) patch.resumeToolState = resumeToolState + } + // Defer marking these interrupts resolved/cancelled until the run + // succeeds (see commitPendingResumes). Committing here would consume the + // approval even if the run then failed, breaking a retry. + const state = runState.get(ctx) + if (state && pending.length > 0) { + state.pendingResumes = { pending, resumeByInterruptId } + } + } + + await createOrResumeRun(runs, ctx.runId, ctx.threadId) + + if (wantsMessages && persistence.stores.messages) { + const state = runState.get(ctx) + if (!state?.merged) { + if (state) state.merged = true + const stored = await persistence.stores.messages.loadThread( + ctx.threadId, + ) + patch.messages = config.messages.length > 0 ? config.messages : stored + } + } + + return Object.keys(patch).length > 0 ? patch : undefined + }, + + async onStart(ctx: ChatMiddlewareContext) { + // (A) Persist the pending turn (the just-submitted user message plus any + // prior history) as soon as the run starts, so a reload mid-run rehydrates + // it before the assistant reply exists. Best-effort: a failed eager + // snapshot must not abort the run — the authoritative save is `onFinish`. + if (!wantsMessages || !persistence.stores.messages) return + try { + await persistence.stores.messages.saveThread(ctx.threadId, [ + ...ctx.messages, + ]) + } catch { + // Eager pre-save is best-effort; the run continues and onFinish saves. + } + }, + + async onChunk(ctx: ChatMiddlewareContext, chunk: StreamChunk) { + // Always capture the current assistant turn's stream messageId (cheap), + // regardless of snapshotStreaming — it's persisted onto the assistant + // message so its identity survives hydrate and a reload resumes the same + // bubble in place. + if (chunk.type === 'TEXT_MESSAGE_START') { + const s = runState.get(ctx) + if (s) { + s.streamingMessageId = chunk.messageId + s.streamingText = '' + } + } + + // (B) Optional throttled snapshot of the in-progress assistant reply, so + // partial output survives a crash/reload before onFinish. Off unless + // `snapshotStreaming` is set. We accumulate the terminal turn's text here + // (the engine only appends assistant turns with tool calls to + // `ctx.messages`, never a streaming text reply), then persist + // `ctx.messages` + that partial assistant message (tagged with its id). + if ( + snapshotStreaming && + wantsMessages && + persistence.stores.messages && + chunk.type === 'TEXT_MESSAGE_CONTENT' && + typeof chunk.delta === 'string' + ) { + const snapshotState = runState.get(ctx) + if (snapshotState) { + snapshotState.streamingText = + (snapshotState.streamingText ?? '') + chunk.delta + const now = Date.now() + if (now - (snapshotState.lastSnapshotAt ?? 0) >= snapshotIntervalMs) { + snapshotState.lastSnapshotAt = now + try { + await persistence.stores.messages.saveThread(ctx.threadId, [ + ...ctx.messages, + { + role: 'assistant', + content: snapshotState.streamingText, + ...(snapshotState.streamingMessageId + ? { id: snapshotState.streamingMessageId } + : {}), + }, + ]) + } catch { + // Streaming snapshots are best-effort; onFinish persists final. + } + } + } + } + + // State-only: react to the interrupt boundary (create interrupt records, + // mark the run interrupted, snapshot thread messages). The chunk stream is + // never mutated — delivery durability is a transport-layer concern. + if ( + chunk.type !== 'RUN_FINISHED' || + chunk.outcome?.type !== 'interrupt' + ) { + return + } + const state = runState.get(ctx) + if (!state) return + + if (wantsInterrupts && persistence.stores.interrupts) { + // The run reached a new interrupt boundary, so the resumes it consumed + // are committed before the fresh interrupts are recorded. + await commitPendingResumes(state, persistence.stores.interrupts) + for (const interrupt of chunk.outcome.interrupts) { + await persistence.stores.interrupts.create({ + interruptId: interrupt.id, + runId: ctx.runId, + threadId: ctx.threadId, + requestedAt: Date.now(), + payload: interruptPayload(interrupt), + }) + } + } + await interruptRun(runs, ctx.runId) + if (wantsMessages && persistence.stores.messages) { + await persistence.stores.messages.saveThread(ctx.threadId, [ + ...ctx.messages, + ]) + } + state.interrupted = true + }, + + async onFinish(ctx: ChatMiddlewareContext, info: FinishInfo) { + const state = runState.get(ctx) + if (state?.interrupted) return + // The run completed successfully, so commit the resumes it consumed. + await commitPendingResumes(state, persistence.stores.interrupts) + await completeRun(runs, ctx.runId, info.usage) + if (wantsMessages && persistence.stores.messages) { + await persistence.stores.messages.saveThread( + ctx.threadId, + finishedTranscript(ctx.messages, info, state?.streamingMessageId), + ) + } + }, + + async onError(ctx: ChatMiddlewareContext, info: ErrorInfo) { + await failRun(runs, ctx.runId, info.error) + }, + + async onAbort(ctx: ChatMiddlewareContext, _info: AbortInfo) { + await interruptRun(runs, ctx.runId) + }, + }) +} + +// --------------------------------------------------------------------------- +// Generation middleware +// --------------------------------------------------------------------------- + +/** + * Generation-only persistence middleware. Tracks run status (run records) for + * image, audio, TTS, video, and transcription activities. + */ +export function withGenerationPersistence( + persistence: AIPersistence, +): GenerationMiddleware +export function withGenerationPersistence( + persistence: AIPersistence, +): GenerationMiddleware { + validatePersistenceStoreKeys(persistence) + const { runs } = resolvePersistencePlan(persistence) + + // A generation activity has no thread or agent run: its only stable identity + // is `requestId`, so the run record is keyed by it on both axes. + return { + name: 'generation-persistence', + + async onStart(ctx: GenerationMiddlewareContext) { + await createOrResumeRun(runs, ctx.requestId, ctx.requestId) + }, + + async onFinish( + ctx: GenerationMiddlewareContext, + info: GenerationFinishInfo, + ) { + await completeRun(runs, ctx.requestId, info.usage) + }, + + async onError(ctx: GenerationMiddlewareContext, info: GenerationErrorInfo) { + await failRun(runs, ctx.requestId, info.error) + }, + + async onAbort( + ctx: GenerationMiddlewareContext, + _info: GenerationAbortInfo, + ) { + await interruptRun(runs, ctx.requestId) + }, + } +} diff --git a/packages/ai-persistence/src/reconstruct.ts b/packages/ai-persistence/src/reconstruct.ts new file mode 100644 index 000000000..e2aed4590 --- /dev/null +++ b/packages/ai-persistence/src/reconstruct.ts @@ -0,0 +1,38 @@ +import type { AIPersistence } from './types' + +export interface ReconstructChatOptions { + /** Query parameter carrying the thread id. Defaults to `threadId`. */ + param?: string +} + +/** + * Build a JSON `Response` of a thread's stored messages, for a + * server-authoritative client to hydrate its transcript on load (see the + * client-persistence guide). Reads the thread id from the request query + * (`?threadId=` by default) and returns the stored messages as JSON. + * + * Returns an empty array when the thread id is missing, no `messages` store is + * configured, or the thread is unknown, so the caller never has to special-case + * a first load. + * + * ```ts + * export async function GET(request: Request) { + * return reconstructChat(persistence, request) + * } + * ``` + */ +export async function reconstructChat( + persistence: AIPersistence, + request: Request, + options?: ReconstructChatOptions, +): Promise { + const param = options?.param ?? 'threadId' + const threadId = new URL(request.url).searchParams.get(param) ?? '' + const messages = + threadId && persistence.stores.messages + ? await persistence.stores.messages.loadThread(threadId) + : [] + return new Response(JSON.stringify(messages), { + headers: { 'content-type': 'application/json' }, + }) +} diff --git a/packages/ai-persistence/src/testkit/conformance.ts b/packages/ai-persistence/src/testkit/conformance.ts new file mode 100644 index 000000000..fd24a7c58 --- /dev/null +++ b/packages/ai-persistence/src/testkit/conformance.ts @@ -0,0 +1,379 @@ +/** + * Shared conformance suite for the `AIPersistence` state contract. + * + * Every backend (memory, drizzle, prisma, cloudflare, …) runs this identical + * suite so that schema drift or an implementation gap fails immediately. It + * exercises every method of every store the persistence exposes and is the + * authoritative compatibility gate for the store interfaces in `../types.ts`. + * + * SKIPPING: a backend that deliberately omits a store (e.g. drizzle has no + * `locks`) must declare it in `options.skip`. A store that is absent AND not + * listed in `skip` fails the suite loudly — silent gaps are not allowed. + */ +import { beforeAll, describe, expect, it } from 'vitest' +import type { ModelMessage } from '@tanstack/ai' +import type { AIPersistence, AIPersistenceStores } from '../types' + +type MakePersistence = () => Promise | AIPersistence + +export interface PersistenceConformanceOptions { + /** + * Store keys this backend intentionally does not provide. Any store that is + * absent from the persistence and NOT listed here fails the suite, so a + * dropped/misconfigured store can never pass silently. + */ + skip?: Array +} + +/** + * Register a Vitest suite that validates `makePersistence()` against the full + * `AIPersistence` contract. + */ +export function runPersistenceConformance( + name: string, + makePersistence: MakePersistence, + options?: PersistenceConformanceOptions, +): void { + const skip = new Set(options?.skip ?? []) + + describe(`AIPersistence conformance: ${name}`, () => { + let persistence: AIPersistence + + beforeAll(async () => { + persistence = await makePersistence() + }) + + /** + * Return the store for `key`, or `null` when the backend intentionally + * skips it. Throws (failing the test) when a store is missing but was not + * declared in `options.skip`. + */ + function resolveStore( + key: TKey, + ): NonNullable | null { + const store = persistence.stores[key] + if (store) return store + if (skip.has(key)) return null + throw new Error( + `AIPersistence conformance: store '${key}' is missing. ` + + `Provide it, or pass { skip: ['${key}'] } if the omission is intentional.`, + ) + } + + describe('messages', () => { + it('round-trips a thread and returns [] for unknown threads', async () => { + const store = resolveStore('messages') + if (!store) return + + expect(await store.loadThread('thread-unknown')).toEqual([]) + + await store.saveThread('thread-msg', [ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: 'hello' }, + ]) + expect(await store.loadThread('thread-msg')).toEqual([ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: 'hello' }, + ]) + + // Overwrites, not appends. + await store.saveThread('thread-msg', [ + { role: 'user', content: 'redo' }, + ]) + expect(await store.loadThread('thread-msg')).toEqual([ + { role: 'user', content: 'redo' }, + ]) + }) + + it('round-trips rich message shapes with deep equality', async () => { + const store = resolveStore('messages') + if (!store) return + + const rich: Array = [ + { role: 'user', content: 'plain string' }, + { + // Tool-call message with JSON arguments. + role: 'assistant', + content: '', + toolCalls: [ + { + id: 'call-1', + type: 'function', + function: { + name: 'search', + arguments: '{"query":"weather in Paris"}', + }, + }, + ], + }, + { + // Tool result message. + role: 'tool', + content: '{"temperature":21,"unit":"C"}', + toolCallId: 'call-1', + }, + { + // Multi-part content: text + image reference. + role: 'user', + content: [ + { type: 'text', content: 'What is in this image?' }, + { + type: 'image', + source: { + type: 'url', + value: 'https://example.com/cat.png', + mimeType: 'image/png', + }, + }, + ], + }, + { + // Reasoning / thinking part. + role: 'assistant', + content: 'Here is my answer.', + thinking: [ + { + content: 'The user is asking about the image.', + signature: 'sig-1', + }, + ], + }, + ] + + await store.saveThread('thread-rich', rich) + expect(await store.loadThread('thread-rich')).toEqual(rich) + }) + }) + + describe('runs', () => { + it('creates, resumes idempotently, updates, and gets', async () => { + const store = resolveStore('runs') + if (!store) return + + expect(await store.get('run-missing')).toBeNull() + + const created = await store.createOrResume({ + runId: 'run-1', + threadId: 'thread-1', + startedAt: 1000, + }) + expect(created).toMatchObject({ + runId: 'run-1', + threadId: 'thread-1', + status: 'running', + startedAt: 1000, + }) + + // createOrResume is idempotent: returns the existing record unchanged. + const resumed = await store.createOrResume({ + runId: 'run-1', + threadId: 'thread-different', + startedAt: 9999, + }) + expect(resumed).toMatchObject({ + runId: 'run-1', + threadId: 'thread-1', + startedAt: 1000, + }) + + await store.update('run-1', { + status: 'completed', + finishedAt: 2000, + usage: { promptTokens: 3, completionTokens: 4, totalTokens: 7 }, + }) + const done = await store.get('run-1') + expect(done).toMatchObject({ + runId: 'run-1', + status: 'completed', + finishedAt: 2000, + usage: { promptTokens: 3, completionTokens: 4, totalTokens: 7 }, + }) + + await store.update('run-1', { status: 'failed', error: 'boom' }) + const failed = await store.get('run-1') + expect(failed?.status).toBe('failed') + expect(failed?.error).toBe('boom') + + // Updating a missing run is a no-op (does not throw, does not create). + await store.update('run-absent', { status: 'completed' }) + expect(await store.get('run-absent')).toBeNull() + }) + }) + + describe('interrupts', () => { + it('creates, resolves, cancels, and lists by thread and run', async () => { + const store = resolveStore('interrupts') + if (!store) return + + expect(await store.get('int-missing')).toBeNull() + + await store.create({ + interruptId: 'int-1', + runId: 'run-i', + threadId: 'thread-i', + requestedAt: 10, + payload: { tool: 'search', args: { q: 'x' } }, + }) + await store.create({ + interruptId: 'int-2', + runId: 'run-i', + threadId: 'thread-i', + requestedAt: 20, + payload: { tool: 'write' }, + }) + await store.create({ + interruptId: 'int-3', + runId: 'run-other', + threadId: 'thread-i', + requestedAt: 30, + payload: {}, + }) + + const one = await store.get('int-1') + expect(one).toMatchObject({ + interruptId: 'int-1', + runId: 'run-i', + threadId: 'thread-i', + status: 'pending', + requestedAt: 10, + payload: { tool: 'search', args: { q: 'x' } }, + }) + + expect( + (await store.list('thread-i')).map((r) => r.interruptId), + ).toEqual(['int-1', 'int-2', 'int-3']) + expect( + (await store.listByRun('run-i')).map((r) => r.interruptId), + ).toEqual(['int-1', 'int-2']) + expect( + (await store.listPending('thread-i')).map((r) => r.interruptId), + ).toEqual(['int-1', 'int-2', 'int-3']) + + await store.resolve('int-1', { ok: true }) + const resolved = await store.get('int-1') + expect(resolved?.status).toBe('resolved') + expect(resolved?.response).toEqual({ ok: true }) + expect(typeof resolved?.resolvedAt).toBe('number') + + await store.cancel('int-2') + const cancelled = await store.get('int-2') + expect(cancelled?.status).toBe('cancelled') + expect(typeof cancelled?.resolvedAt).toBe('number') + + expect( + (await store.listPending('thread-i')).map((r) => r.interruptId), + ).toEqual(['int-3']) + expect( + (await store.listPendingByRun('run-i')).map((r) => r.interruptId), + ).toEqual([]) + }) + + it('create is insert-if-absent: a duplicate id never clobbers a resolved interrupt', async () => { + const store = resolveStore('interrupts') + if (!store) return + + await store.create({ + interruptId: 'int-dup', + runId: 'run-dup', + threadId: 'thread-dup', + requestedAt: 100, + payload: { attempt: 1 }, + }) + await store.resolve('int-dup', { answer: 42 }) + + // A second create with the SAME id must be a no-op — not overwrite the + // now-resolved record back to pending with a fresh payload. + await store.create({ + interruptId: 'int-dup', + runId: 'run-dup', + threadId: 'thread-dup', + requestedAt: 200, + payload: { attempt: 2 }, + }) + + const after = await store.get('int-dup') + expect(after?.status).toBe('resolved') + expect(after?.response).toEqual({ answer: 42 }) + expect(after?.payload).toEqual({ attempt: 1 }) + expect(after?.requestedAt).toBe(100) + }) + }) + + describe('metadata', () => { + it('sets, gets, scopes, and deletes', async () => { + const store = resolveStore('metadata') + if (!store) return + + expect(await store.get('scope-a', 'k')).toBeNull() + + await store.set('scope-a', 'k', { n: 1 }) + await store.set('scope-b', 'k', { n: 2 }) + expect(await store.get('scope-a', 'k')).toEqual({ n: 1 }) + expect(await store.get('scope-b', 'k')).toEqual({ n: 2 }) + + await store.set('scope-a', 'k', { n: 3 }) + expect(await store.get('scope-a', 'k')).toEqual({ n: 3 }) + + await store.delete('scope-a', 'k') + expect(await store.get('scope-a', 'k')).toBeNull() + // Delete is scoped: scope-b untouched. + expect(await store.get('scope-b', 'k')).toEqual({ n: 2 }) + }) + }) + describe('locks', () => { + it('runs the critical section and returns its value', async () => { + const store = resolveStore('locks') + if (!store) return + + const order: Array = [] + const result = await store.withLock('lock-key', () => { + order.push('inside') + return Promise.resolve(42) + }) + expect(result).toBe(42) + expect(order).toEqual(['inside']) + }) + + it('serializes concurrent holders of the same key (mutual exclusion)', async () => { + const store = resolveStore('locks') + if (!store) return + + let active = 0 + let overlaps = 0 + const enterExit = async () => { + active += 1 + if (active > 1) overlaps += 1 + // Yield so a second critical section would interleave if the lock + // failed to exclude it. + await Promise.resolve() + await Promise.resolve() + active -= 1 + } + + await Promise.all([ + store.withLock('mx-key', enterExit), + store.withLock('mx-key', enterExit), + store.withLock('mx-key', enterExit), + ]) + + expect(overlaps).toBe(0) + }) + + it('releases the lock when the critical section throws', async () => { + const store = resolveStore('locks') + if (!store) return + + await expect( + store.withLock('throw-key', () => Promise.reject(new Error('boom'))), + ).rejects.toThrow('boom') + + // The lock must have been released despite the throw: a subsequent + // acquisition runs rather than deadlocking. + const result = await store.withLock('throw-key', () => + Promise.resolve('recovered'), + ) + expect(result).toBe('recovered') + }) + }) + }) +} diff --git a/packages/ai-persistence/src/types.ts b/packages/ai-persistence/src/types.ts new file mode 100644 index 000000000..6213c4e36 --- /dev/null +++ b/packages/ai-persistence/src/types.ts @@ -0,0 +1,386 @@ +import type { ModelMessage, TokenUsage } from '@tanstack/ai' +import type { LockStore } from './locks' + +// =========================================================================== +// Store contracts +// =========================================================================== +// +// EVOLUTION POLICY +// ---------------- +// These store interfaces are the compatibility surface between the core +// middleware and every backend (memory, drizzle, prisma, cloudflare, …). +// To avoid breaking existing adapters: +// +// - New store methods are added as OPTIONAL (`method?: (...) => ...`). The +// middleware feature-detects them (`store.method?.(...)`) and degrades +// gracefully when a backend has not implemented them yet. +// - Never tighten an existing method's required arguments or widen its +// required return shape in a breaking way. +// +// The shared conformance testkit (`./testkit/conformance.ts`) is the +// authoritative compatibility gate: every invariant documented on the methods +// below is asserted there, and every backend runs the identical suite. If an +// invariant is not encoded in the testkit, adapters cannot discover it — so +// promote new invariants into both the JSDoc here AND the testkit. +// +// TIMESTAMP CONVENTION +// -------------------- +// Store *records* (`RunRecord`, `InterruptRecord`) speak **epoch +// milliseconds** (`number`), the native unit for SQL/`BIGINT` columns and +// `Date.now()`. Wire/result references that leave the persistence layer speak +// **ISO-8601 strings**. The middleware performs the number→ISO conversion at +// the boundary; do not mix the two on a single field. + +/** + * Durable store for a thread's full message transcript. + * + * A "thread" is the unit of conversation history. `saveThread` always receives + * and persists the **complete, authoritative** message list — it is an + * overwrite, never an append. The middleware snapshots `ctx.messages` (the full + * running transcript) into it. + */ +export interface MessageStore { + /** + * Return the full stored transcript for `threadId`, in insertion order. + * + * INVARIANT: returns an empty array (never `null`/`undefined`) for a thread + * that was never saved. Callers treat `[]` as "no history". + */ + loadThread: (threadId: string) => Promise> + /** + * Overwrite the stored transcript for `threadId` with `messages`. + * + * INVARIANT: this is a full replace. `messages` is the complete authoritative + * history; the previous contents are discarded (not merged or appended). + */ + saveThread: (threadId: string, messages: Array) => Promise +} + +export type RunStatus = 'running' | 'completed' | 'failed' | 'interrupted' + +/** + * A single generation/chat run. + * + * @property startedAt - Epoch ms when the run was first created. + * @property finishedAt - Epoch ms when the run reached a terminal status. + */ +export interface RunRecord { + runId: string + threadId: string + status: RunStatus + startedAt: number + finishedAt?: number + error?: string + usage?: TokenUsage +} + +/** Durable store for run lifecycle records. */ +export interface RunStore { + /** + * Create a run record, or return the existing one if `runId` is already + * present (resume). + * + * INVARIANT (idempotency): if a record for `runId` already exists it is + * returned **unchanged** and the passed `threadId`/`startedAt`/`status` are + * ignored. This is what makes resuming a run safe — the second call for a + * `runId` must not mutate `startedAt`, `threadId`, or status. `status` + * defaults to `'running'` on first creation. + */ + createOrResume: ( + input: Pick & { + status?: RunStatus + }, + ) => Promise + /** + * Patch a run record's mutable fields. + * + * INVARIANT: updating a `runId` that does not exist is a **no-op** — it must + * not throw and must not create a record. + */ + update: ( + runId: string, + patch: Partial< + Pick + >, + ) => Promise + /** Return the run record for `runId`, or `null` if none exists. */ + get: (runId: string) => Promise +} + +/** Lifecycle status of a human-in-the-loop interrupt. */ +export type InterruptStatus = 'pending' | 'resolved' | 'cancelled' + +/** + * A human-in-the-loop interrupt (tool approval, client-tool input request, …). + * + * @property requestedAt - Epoch ms when the interrupt was created. + * @property resolvedAt - Epoch ms when the interrupt was resolved/cancelled; + * absent while pending. + */ +export interface InterruptRecord { + interruptId: string + runId: string + threadId: string + status: InterruptStatus + requestedAt: number + resolvedAt?: number + payload: Record + response?: unknown +} + +/** Durable store for human-in-the-loop interrupts. */ +export interface InterruptStore { + /** + * Persist a new interrupt in the `'pending'` state. + * + * The record is accepted without `status`/`resolvedAt` so a "born resolved" + * interrupt is unrepresentable — every interrupt begins pending and only + * `resolve`/`cancel` may move it to a terminal state. + * + * INVARIANT (insert-if-absent): if an interrupt with the same `interruptId` + * already exists, `create` is a **no-op** — it must NOT overwrite the + * existing record. This is the canonical behaviour (SQL backends implement it + * via `ON CONFLICT DO NOTHING` / upsert-with-empty-update), so a duplicate + * create can never clobber a resolved interrupt back to pending. + */ + create: ( + record: Omit, + ) => Promise + /** + * Move an interrupt to `'resolved'`, stamping `resolvedAt` and storing + * `response`. A no-op if `interruptId` does not exist. + */ + resolve: (interruptId: string, response?: unknown) => Promise + /** + * Move an interrupt to `'cancelled'`, stamping `resolvedAt`. A no-op if + * `interruptId` does not exist. + */ + cancel: (interruptId: string) => Promise + /** Return the interrupt for `interruptId`, or `null` if none exists. */ + get: (interruptId: string) => Promise + /** + * All interrupts for a thread. + * + * INVARIANT: ordered by insertion (equivalently `requestedAt` ascending). SQL + * backends MUST `ORDER BY requested_at` — the middleware and testkit rely on + * this stable ordering. + */ + list: (threadId: string) => Promise> + /** Pending interrupts for a thread, ordered by `requestedAt` ascending. */ + listPending: (threadId: string) => Promise> + /** All interrupts for a run, ordered by `requestedAt` ascending. */ + listByRun: (runId: string) => Promise> + /** Pending interrupts for a run, ordered by `requestedAt` ascending. */ + listPendingByRun: (runId: string) => Promise> +} + +/** + * Scoped key/value store for arbitrary JSON metadata. + * + * `(scope, key)` is the composite identity; the same `key` under different + * scopes is independent. + */ +export interface MetadataStore { + /** + * Return the stored value for `(scope, key)`, or `null` if absent. + * + * CAVEAT: the return type is `unknown | null`, where `| null` collapses into + * `unknown` — a stored value of `null` is therefore **indistinguishable from + * absence** at the type level. Callers that must persist a real `null` + * distinctly from "not set" should wrap it (e.g. store `{ value: null }`). + */ + get: (scope: string, key: string) => Promise + /** Insert or overwrite the value for `(scope, key)`. */ + set: (scope: string, key: string, value: unknown) => Promise + /** Remove `(scope, key)`. A no-op if absent. Does not affect other scopes. */ + delete: (scope: string, key: string) => Promise +} + +export interface AIPersistenceStores { + messages?: MessageStore + runs?: RunStore + interrupts?: InterruptStore + metadata?: MetadataStore + locks?: LockStore +} + +export interface AIPersistence< + TStores extends AIPersistenceStores = AIPersistenceStores, +> { + stores: ExactStoreKeys +} + +type StoreKey = keyof AIPersistenceStores +type ExactStoreKeys = + Exclude extends never + ? TStores + : TStores & Record, never> + +export type AIPersistenceOverrides = { + [TKey in StoreKey]?: AIPersistenceStores[TKey] | false +} + +type BaseStoreValue< + TBase extends AIPersistenceStores, + TKey extends StoreKey, +> = TKey extends keyof TBase ? TBase[TKey] : never + +type OverrideStoreValue< + TOverrides extends AIPersistenceOverrides, + TKey extends StoreKey, +> = TKey extends keyof TOverrides ? TOverrides[TKey] : never + +type ResolvedStoreValue< + TBase extends AIPersistenceStores, + TOverrides extends AIPersistenceOverrides, + TKey extends StoreKey, +> = TKey extends keyof TOverrides + ? + | Exclude, false | undefined> + | (undefined extends OverrideStoreValue + ? Exclude, undefined> + : never) + : Exclude, undefined> + +type BaseStoreIsRequired< + TBase extends AIPersistenceStores, + TKey extends StoreKey, +> = TKey extends keyof TBase + ? object extends Pick + ? false + : true + : false + +type ResolvedStoreIsRequired< + TBase extends AIPersistenceStores, + TOverrides extends AIPersistenceOverrides, + TKey extends StoreKey, +> = TKey extends keyof TOverrides + ? false extends OverrideStoreValue + ? false + : undefined extends OverrideStoreValue + ? BaseStoreIsRequired + : true + : BaseStoreIsRequired + +type ResolvedRequiredKeys< + TBase extends AIPersistenceStores, + TOverrides extends AIPersistenceOverrides, +> = { + [TKey in StoreKey]-?: [ResolvedStoreValue] extends [ + never, + ] + ? never + : ResolvedStoreIsRequired extends true + ? TKey + : never +}[StoreKey] + +type ResolvedOptionalKeys< + TBase extends AIPersistenceStores, + TOverrides extends AIPersistenceOverrides, +> = { + [TKey in StoreKey]-?: [ResolvedStoreValue] extends [ + never, + ] + ? never + : ResolvedStoreIsRequired extends true + ? never + : TKey +}[StoreKey] + +type Simplify = { [TKey in keyof T]: T[TKey] } + +export type ComposedAIPersistenceStores< + TBase extends AIPersistenceStores, + TOverrides extends AIPersistenceOverrides, +> = Simplify< + { + [TKey in ResolvedRequiredKeys]: ResolvedStoreValue< + TBase, + TOverrides, + TKey + > + } & { + [TKey in ResolvedOptionalKeys]?: ResolvedStoreValue< + TBase, + TOverrides, + TKey + > + } +> + +const storeKeys = [ + 'messages', + 'runs', + 'interrupts', + 'metadata', + 'locks', +] satisfies Array + +const storeKeySet = new Set(storeKeys) + +function assertKnownStoreKeys(stores: object, location: string): void { + for (const key of Object.keys(stores)) { + if (!storeKeySet.has(key)) { + throw new Error(`Unknown AIPersistence ${location} key: ${key}`) + } + } +} + +export function validatePersistenceStoreKeys(persistence: AIPersistence): void { + assertKnownStoreKeys(persistence.stores, 'store') +} + +export function validateChatPersistenceStores( + persistence: AIPersistence, +): void { + validatePersistenceStoreKeys(persistence) + if (persistence.stores.interrupts && !persistence.stores.runs) { + throw new Error('Chat persistence stores.interrupts requires stores.runs.') + } +} + +export function defineAIPersistence( + persistence: AIPersistence>, +): AIPersistence { + validatePersistenceStoreKeys(persistence) + return persistence +} + +export function composePersistence< + TBase extends AIPersistenceStores, + TOverrides extends AIPersistenceOverrides, +>( + base: AIPersistence, + config: { + overrides: ExactStoreKeys + }, +): AIPersistence> +export function composePersistence( + base: AIPersistence, + config: { overrides: AIPersistenceOverrides }, +): AIPersistence { + validatePersistenceStoreKeys(base) + assertKnownStoreKeys(config.overrides, 'override') + + const stores: AIPersistenceStores = { ...base.stores } + for (const key of storeKeys) { + if (!Object.prototype.hasOwnProperty.call(config.overrides, key)) continue + const override = config.overrides[key] + if (override === false) { + delete stores[key] + } else if (override !== undefined) { + setStore(stores, key, override) + } + } + return { stores } +} + +function setStore( + stores: AIPersistenceStores, + key: TKey, + value: NonNullable, +): void { + stores[key] = value +} diff --git a/packages/ai-persistence/tests/capabilities.test.ts b/packages/ai-persistence/tests/capabilities.test.ts new file mode 100644 index 000000000..05557b70c --- /dev/null +++ b/packages/ai-persistence/tests/capabilities.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest' +import { EventType, chat, defineChatMiddleware } from '@tanstack/ai' +import type { + AnyTextAdapter, + ChatMiddlewareContext, + StreamChunk, +} from '@tanstack/ai' +import type { LockStore } from '../src/locks' +import { memoryPersistence } from '../src/memory' +import { withPersistence } from '../src/middleware' +import { + InterruptsCapability, + LocksCapability, + PersistenceCapability, + getInterrupts, + getLocks, + getPersistence, +} from '../src/capabilities' +import { createInterruptController } from '../src/interrupts' +import type { AIPersistence, InterruptStore } from '../src' + +function mockAdapter(chunks: Array) { + return { + kind: 'text', + name: 'mock', + model: 'test-model', + '~types': {}, + chatStream: () => + (async function* () { + for (const c of chunks) yield c + })(), + structuredOutput: async () => ({ data: {}, rawText: '{}' }), + } as unknown as AnyTextAdapter +} + +async function collect(stream: AsyncIterable) { + const out: Array = [] + for await (const c of stream) out.push(c) + return out +} + +describe('persistence capabilities', () => { + it('provides persistence, interrupts, and locks to downstream middleware', async () => { + const persistence = memoryPersistence() + const seen: { + persistence?: AIPersistence + interrupts?: InterruptStore + locks?: LockStore + } = {} + + const consumer = defineChatMiddleware({ + name: 'capability-consumer', + requires: [PersistenceCapability, InterruptsCapability, LocksCapability], + setup(ctx: ChatMiddlewareContext) { + seen.persistence = getPersistence(ctx) + seen.interrupts = getInterrupts(ctx) + seen.locks = getLocks(ctx) + }, + }) + + await collect( + chat({ + adapter: mockAdapter([ + { + type: EventType.RUN_STARTED, + runId: 'r1', + threadId: 't1', + timestamp: 1, + }, + { + type: EventType.RUN_FINISHED, + runId: 'r1', + threadId: 't1', + finishReason: 'stop', + timestamp: 1, + }, + ]), + messages: [{ role: 'user', content: 'hi' }], + runId: 'r1', + threadId: 't1', + middleware: [withPersistence(persistence), consumer], + }) as AsyncIterable, + ) + + expect(seen.persistence).toBe(persistence) + expect(seen.interrupts).toBe(persistence.stores.interrupts) + expect(seen.locks).toBe(persistence.stores.locks) + }) +}) + +describe('createInterruptController', () => { + it('delegates request/resolve/cancel/list to the underlying store', async () => { + const persistence = memoryPersistence() + const controller = createInterruptController({ + store: persistence.stores.interrupts!, + }) + + await controller.request({ + interruptId: 'c1', + runId: 'run-c', + threadId: 'thread-c', + requestedAt: 1, + payload: { kind: 'approval' }, + }) + expect(await controller.listPending('thread-c')).toHaveLength(1) + expect(await controller.listPendingByRun('run-c')).toHaveLength(1) + + await controller.resolve('c1', { approved: true }) + expect((await persistence.stores.interrupts!.get('c1'))?.status).toBe( + 'resolved', + ) + expect((await persistence.stores.interrupts!.get('c1'))?.response).toEqual({ + approved: true, + }) + expect(await controller.listPending('thread-c')).toHaveLength(0) + + await controller.request({ + interruptId: 'c2', + runId: 'run-c', + threadId: 'thread-c', + requestedAt: 2, + payload: {}, + }) + await controller.cancel('c2') + expect((await persistence.stores.interrupts!.get('c2'))?.status).toBe( + 'cancelled', + ) + }) + + it('creates interrupts in the pending state', async () => { + const persistence = memoryPersistence() + const controller = createInterruptController({ + store: persistence.stores.interrupts!, + }) + await controller.request({ + interruptId: 'c1', + runId: 'run-c', + threadId: 'thread-c', + requestedAt: 1, + payload: {}, + }) + expect((await persistence.stores.interrupts!.get('c1'))?.status).toBe( + 'pending', + ) + }) +}) diff --git a/packages/ai-persistence/tests/error-abort.test.ts b/packages/ai-persistence/tests/error-abort.test.ts new file mode 100644 index 000000000..4bfeb1a45 --- /dev/null +++ b/packages/ai-persistence/tests/error-abort.test.ts @@ -0,0 +1,301 @@ +import { describe, expect, it, vi } from 'vitest' +import { EventType, chat, generateImage } from '@tanstack/ai' +import type { + AnyTextAdapter, + GenerationAbortInfo, + GenerationErrorInfo, + GenerationMiddlewareContext, + ImageAdapter, + StreamChunk, +} from '@tanstack/ai' +import { memoryPersistence } from '../src/memory' +import { withPersistence, withGenerationPersistence } from '../src/middleware' +import { composePersistence } from '../src/types' + +function mockAdapter(iterations: Array>) { + const calls: Array = [] + let i = 0 + const adapter = { + kind: 'text', + name: 'mock', + model: 'test-model', + '~types': {}, + chatStream: (opts: unknown) => { + calls.push(opts) + const chunks = iterations[i] ?? [] + i++ + return (async function* () { + for (const c of chunks) yield c + })() + }, + structuredOutput: async () => ({ data: {}, rawText: '{}' }), + } as unknown as AnyTextAdapter + return { adapter, calls } +} + +const runStarted = (): StreamChunk => ({ + type: EventType.RUN_STARTED, + runId: 'r1', + threadId: 't1', + timestamp: 1, +}) + +const interruptFinished = (): StreamChunk => ({ + type: EventType.RUN_FINISHED, + runId: 'r1', + threadId: 't1', + finishReason: 'tool_calls', + timestamp: 1, + outcome: { + type: 'interrupt', + interrupts: [{ id: 'interrupt-1', reason: 'tool_call', toolCallId: 'tc1' }], + }, +}) + +async function collect(stream: AsyncIterable) { + const out: Array = [] + for await (const c of stream) out.push(c) + return out +} + +function throwingChatAdapter(thrown: unknown): AnyTextAdapter { + return { + kind: 'text', + name: 'mock', + model: 'test-model', + '~types': {}, + chatStream: () => + (async function* () { + yield runStarted() + throw thrown + })(), + structuredOutput: async () => ({ data: {}, rawText: '{}' }), + } as unknown as AnyTextAdapter +} + +describe('chat persistence error/abort hooks', () => { + it('marks the run failed when the provider throws mid-stream', async () => { + const persistence = memoryPersistence() + + await expect( + collect( + chat({ + adapter: throwingChatAdapter(new Error('provider exploded')), + messages: [{ role: 'user', content: 'hi' }], + runId: 'r1', + threadId: 't1', + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ), + ).rejects.toThrow('provider exploded') + + const run = await persistence.stores.runs!.get('r1') + expect(run?.status).toBe('failed') + expect(run?.error).toBe('provider exploded') + }) + + it('coerces a non-Error thrown value into the run error string', async () => { + const persistence = memoryPersistence() + + await expect( + collect( + chat({ + adapter: throwingChatAdapter('string failure'), + messages: [{ role: 'user', content: 'hi' }], + runId: 'r1', + threadId: 't1', + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ), + ).rejects.toBeDefined() + + const run = await persistence.stores.runs!.get('r1') + expect(run?.status).toBe('failed') + expect(run?.error).toBe('string failure') + }) + + it('propagates and does not swallow a store error thrown while recording an interrupt', async () => { + const base = memoryPersistence() + const real = base.stores.interrupts! + const createError = new Error('interrupts.create failed') + const persistence = composePersistence(base, { + overrides: { + interrupts: { + create: () => Promise.reject(createError), + resolve: (id, r) => real.resolve(id, r), + cancel: (id) => real.cancel(id), + get: (id) => real.get(id), + list: (t) => real.list(t), + listPending: (t) => real.listPending(t), + listByRun: (r) => real.listByRun(r), + listPendingByRun: (r) => real.listPendingByRun(r), + }, + }, + }) + + await expect( + collect( + chat({ + adapter: mockAdapter([[runStarted(), interruptFinished()]]).adapter, + messages: [{ role: 'user', content: 'hi' }], + runId: 'r1', + threadId: 't1', + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ), + ).rejects.toThrow('interrupts.create failed') + }) + + it('leaves the interrupt persisted when the follow-up thread snapshot fails (partial write)', async () => { + const base = memoryPersistence() + const realMessages = base.stores.messages! + const saveError = new Error('saveThread failed') + const persistence = composePersistence(base, { + overrides: { + messages: { + loadThread: (t) => realMessages.loadThread(t), + saveThread: () => Promise.reject(saveError), + }, + }, + }) + + await expect( + collect( + chat({ + adapter: mockAdapter([[runStarted(), interruptFinished()]]).adapter, + messages: [{ role: 'user', content: 'hi' }], + runId: 'r1', + threadId: 't1', + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ), + ).rejects.toThrow('saveThread failed') + + // The interrupt was created before the failing snapshot, so it survives: + // recovery/retry can still see the pending interrupt. + const pending = await base.stores.interrupts!.listPending('t1') + expect(pending.map((p) => p.interruptId)).toEqual(['interrupt-1']) + }) +}) + +function imageAdapterThatThrows(thrown: unknown): ImageAdapter { + return { + kind: 'image', + name: 'test-image-provider', + model: 'test-image-model', + '~types': { + providerOptions: {}, + modelProviderOptionsByName: {}, + modelSizeByName: {}, + modelInputModalitiesByName: {}, + }, + generateImages: vi.fn(() => Promise.reject(thrown)), + } +} + +// A generation activity's only identity is `requestId` (auto-generated), so the +// integration tests capture it via a probe middleware, and the direct-drive +// tests set `requestId` to the pre-created run's id. +function generationContext(requestId: string): GenerationMiddlewareContext { + return { + requestId, + activity: 'image', + provider: 'test', + model: 'test-model', + source: 'server', + createId: (prefix) => `${prefix}-1`, + context: undefined, + } +} + +describe('generation persistence error/abort hooks', () => { + it('marks the run failed when generation throws', async () => { + const persistence = memoryPersistence() + let requestId = '' + + await expect( + generateImage({ + adapter: imageAdapterThatThrows(new Error('image boom')), + prompt: 'make an image', + middleware: [ + { + onStart: (ctx) => { + requestId = ctx.requestId + }, + }, + withGenerationPersistence(persistence), + ], + }), + ).rejects.toThrow('image boom') + + const run = await persistence.stores.runs!.get(requestId) + expect(run?.status).toBe('failed') + expect(run?.error).toBe('image boom') + }) + + it('coerces a non-Error generation failure into the run error string', async () => { + const persistence = memoryPersistence() + let requestId = '' + + await expect( + generateImage({ + adapter: imageAdapterThatThrows('image string failure'), + prompt: 'make an image', + middleware: [ + { + onStart: (ctx) => { + requestId = ctx.requestId + }, + }, + withGenerationPersistence(persistence), + ], + }), + ).rejects.toBeDefined() + + const run = await persistence.stores.runs!.get(requestId) + expect(run?.status).toBe('failed') + expect(run?.error).toBe('image string failure') + }) + + it('marks the run interrupted on generation abort', async () => { + const persistence = memoryPersistence() + const middleware = withGenerationPersistence(persistence) + + await persistence.stores.runs!.createOrResume({ + runId: 'req-abort', + threadId: 'req-abort', + startedAt: 1, + }) + + // Drive the abort hook directly: only long-poll activities (video) route + // through onAbort at runtime, so exercise the handler in isolation. + const abortInfo: GenerationAbortInfo = { + duration: 1, + reason: 'client cancelled', + } + await middleware.onAbort?.(generationContext('req-abort'), abortInfo) + + expect((await persistence.stores.runs!.get('req-abort'))?.status).toBe( + 'interrupted', + ) + }) + + it('coerces a non-Error into the run error string via the onError handler', async () => { + const persistence = memoryPersistence() + const middleware = withGenerationPersistence(persistence) + await persistence.stores.runs!.createOrResume({ + runId: 'req-err', + threadId: 'req-err', + startedAt: 1, + }) + const errorInfo: GenerationErrorInfo = { + error: { code: 500 }, + duration: 1, + } + await middleware.onError?.(generationContext('req-err'), errorInfo) + + const run = await persistence.stores.runs!.get('req-err') + expect(run?.status).toBe('failed') + expect(run?.error).toBe('[object Object]') + }) +}) diff --git a/packages/ai-persistence/tests/interrupts.test.ts b/packages/ai-persistence/tests/interrupts.test.ts new file mode 100644 index 000000000..752bf4eab --- /dev/null +++ b/packages/ai-persistence/tests/interrupts.test.ts @@ -0,0 +1,872 @@ +import { describe, expect, it, vi } from 'vitest' +import { EventType, chat } from '@tanstack/ai' +import type { AnyTextAdapter, StreamChunk, Tool } from '@tanstack/ai' +import { memoryPersistence } from '../src/memory' +import { withPersistence } from '../src/middleware' + +function mockAdapter(iterations: Array>) { + const calls: Array = [] + let i = 0 + const adapter = { + kind: 'text', + name: 'mock', + model: 'test-model', + '~types': {}, + chatStream: (opts: unknown) => { + calls.push(opts) + const chunks = iterations[i] ?? [] + i++ + return (async function* () { + for (const c of chunks) yield c + })() + }, + structuredOutput: async () => ({ data: {}, rawText: '{}' }), + } as unknown as AnyTextAdapter + return { adapter, calls } +} + +async function collect(stream: AsyncIterable) { + const out: Array = [] + for await (const c of stream) out.push(c) + return out +} + +const interruptFinished = (runId = 'r1'): StreamChunk => ({ + type: EventType.RUN_FINISHED, + runId, + threadId: 't1', + finishReason: 'tool_calls', + timestamp: 1, + outcome: { + type: 'interrupt', + interrupts: [ + { + id: 'interrupt-1', + reason: 'tool_call', + message: 'Approve the tool call?', + toolCallId: 'tool-call-1', + }, + ], + }, +}) + +const runStarted = (): StreamChunk => ({ + type: EventType.RUN_STARTED, + runId: 'r1', + threadId: 't1', + timestamp: 1, +}) + +const toolStart = (): StreamChunk => ({ + type: EventType.TOOL_CALL_START, + toolCallId: 'tool-call-1', + toolCallName: 'clientSearch', + toolName: 'clientSearch', + timestamp: 1, +}) + +const toolArgs = (): StreamChunk => ({ + type: EventType.TOOL_CALL_ARGS, + toolCallId: 'tool-call-1', + delta: '{"query":"test"}', + timestamp: 1, +}) + +const text = (delta: string): StreamChunk => ({ + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: 'm1', + delta, + timestamp: 1, +}) + +const runFinished = (runId = 'r1'): StreamChunk => ({ + type: EventType.RUN_FINISHED, + runId, + threadId: 't1', + finishReason: 'stop', + timestamp: 1, +}) + +const clientTool = (name: string): Tool => ({ + name, + description: `${name} client tool`, +}) + +const approvalClientTool = (name: string): Tool => ({ + ...clientTool(name), + needsApproval: true, +}) + +describe('interrupt persistence', () => { + it('persists RUN_FINISHED interrupt outcomes as pending interrupt records', async () => { + const persistence = memoryPersistence() + const { adapter } = mockAdapter([[runStarted(), interruptFinished()]]) + + const chunks = await collect( + chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + runId: 'r1', + threadId: 't1', + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + + const pending = await persistence.stores.interrupts!.listPending('t1') + expect(pending).toHaveLength(1) + expect(pending[0]?.interruptId).toBe('interrupt-1') + expect((await persistence.stores.runs!.get('r1'))?.status).toBe( + 'interrupted', + ) + // Persistence is state-only: it never stamps delivery cursors on the stream. + expect(chunks.every((chunk) => !('cursor' in chunk))).toBe(true) + }) + + it('saves thread messages when a messages-enabled run pauses on an interrupt', async () => { + const persistence = memoryPersistence() + const { adapter } = mockAdapter([[runStarted(), interruptFinished()]]) + + await collect( + chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + runId: 'r1', + threadId: 't1', + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + + expect(await persistence.stores.messages!.loadThread('t1')).toEqual([ + { role: 'user', content: 'hi' }, + ]) + }) + + it('does not persist duplicate records before terminal interrupt outcome', async () => { + const persistence = memoryPersistence() + const create = vi.spyOn(persistence.stores.interrupts!, 'create') + const { adapter } = mockAdapter([ + [ + runStarted(), + toolStart(), + toolArgs(), + { + type: EventType.RUN_FINISHED, + runId: 'r1', + threadId: 't1', + finishReason: 'tool_calls', + timestamp: 1, + }, + ], + ]) + + await collect( + chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + tools: [approvalClientTool('clientSearch')], + runId: 'r1', + threadId: 't1', + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + + expect(create).toHaveBeenCalledTimes(1) + expect(await persistence.stores.interrupts!.listPending('t1')).toHaveLength( + 1, + ) + }) + + it('blocks normal new input while a thread has pending interrupts', async () => { + const persistence = memoryPersistence() + await persistence.stores.interrupts!.create({ + interruptId: 'interrupt-1', + runId: 'old-run', + threadId: 't1', + requestedAt: 1, + payload: {}, + }) + const { adapter } = mockAdapter([[interruptFinished()]]) + + await expect( + collect( + chat({ + adapter, + messages: [{ role: 'user', content: 'new input' }], + runId: 'r2', + threadId: 't1', + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ), + ).rejects.toThrow(/pending interrupt/i) + + expect(await persistence.stores.runs!.get('r2')).toBeNull() + }) + + it('treats resume entries as interrupt continuation on the same run', async () => { + const persistence = memoryPersistence() + const first = mockAdapter([[runStarted(), interruptFinished()]]) + await collect( + chat({ + adapter: first.adapter, + messages: [{ role: 'user', content: 'hi' }], + runId: 'r1', + threadId: 't1', + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + expect(await persistence.stores.interrupts!.listPending('t1')).toHaveLength( + 1, + ) + + const continuation = mockAdapter([ + [runStarted(), text('continued'), runFinished('r1')], + ]) + const chunks = await collect( + chat({ + adapter: continuation.adapter, + messages: [], + runId: 'r1', + threadId: 't1', + resume: [ + { + interruptId: 'interrupt-1', + status: 'resolved', + payload: { approved: true }, + }, + ], + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + + expect(continuation.calls).toHaveLength(1) + expect(chunks).toContainEqual( + expect.objectContaining({ delta: 'continued' }), + ) + expect(await persistence.stores.interrupts!.listPending('t1')).toEqual([]) + expect( + (await persistence.stores.interrupts!.get('interrupt-1'))?.status, + ).toBe('resolved') + }) + + // The full two-phase chain for an approval-required client tool, driven + // entirely from persisted server state with empty client `messages`: + // phase 1: model requests the tool -> approval interrupt pending + // phase 2: resume approves -> client-execution interrupt pending + // phase 3: resume supplies output -> tool result fed back, model finishes + // + // The engine reprocesses the pending tool call from the thread the middleware + // rehydrates (not from the omitted client history), so approving does NOT + // re-invoke the model — it advances straight to the client-execution + // interrupt. Feeding the client output then drives exactly one model call. + it('applies persisted approval and client-tool resume decisions with empty client messages', async () => { + const persistence = memoryPersistence() + const toolCallChunks = () => [ + runStarted(), + toolStart(), + toolArgs(), + { + type: EventType.RUN_FINISHED, + runId: 'r1', + threadId: 't1', + finishReason: 'tool_calls', + timestamp: 1, + } as StreamChunk, + ] + const first = mockAdapter([toolCallChunks()]) + await collect( + chat({ + adapter: first.adapter, + messages: [{ role: 'user', content: 'hi' }], + tools: [approvalClientTool('clientSearch')], + runId: 'r1', + threadId: 't1', + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + + const approvalInterrupt = await persistence.stores.interrupts!.get( + 'approval_tool-call-1', + ) + expect(approvalInterrupt?.status).toBe('pending') + + const afterApproval = mockAdapter([]) + const approvalChunks = await collect( + chat({ + adapter: afterApproval.adapter, + messages: [], + tools: [approvalClientTool('clientSearch')], + runId: 'r1', + threadId: 't1', + resume: [ + { + interruptId: 'approval_tool-call-1', + status: 'resolved', + payload: { approved: true }, + }, + ], + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + + // Approving a client tool does not call the model: the engine reprocesses + // the rehydrated tool call and requests client execution directly. + expect(afterApproval.calls).toHaveLength(0) + expect( + approvalChunks.find( + (chunk) => + chunk.type === EventType.RUN_FINISHED && + chunk.outcome?.type === 'interrupt', + ), + ).toMatchObject({ + outcome: { + interrupts: [ + { + id: 'client_tool_tool-call-1', + toolCallId: 'tool-call-1', + }, + ], + }, + }) + expect( + (await persistence.stores.interrupts!.get('approval_tool-call-1')) + ?.status, + ).toBe('resolved') + expect( + (await persistence.stores.interrupts!.get('client_tool_tool-call-1')) + ?.status, + ).toBe('pending') + + const afterClientTool = mockAdapter([ + [runStarted(), text('done'), runFinished('r1')], + ]) + const finalChunks = await collect( + chat({ + adapter: afterClientTool.adapter, + messages: [], + tools: [clientTool('clientSearch')], + runId: 'r1', + threadId: 't1', + resume: [ + { + interruptId: 'client_tool_tool-call-1', + status: 'resolved', + payload: { answer: 42 }, + }, + ], + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + + // The client output is fed back as a tool result, then a single model call + // produces the final answer. + expect(afterClientTool.calls).toHaveLength(1) + expect(finalChunks).toContainEqual( + expect.objectContaining({ + type: EventType.TOOL_CALL_RESULT, + toolCallId: 'tool-call-1', + content: JSON.stringify({ answer: 42 }), + }), + ) + expect(finalChunks).toContainEqual( + expect.objectContaining({ delta: 'done' }), + ) + expect(await persistence.stores.interrupts!.listPending('t1')).toEqual([]) + }) + + it('rejects invalid resume entries against pending interrupts', async () => { + const persistence = memoryPersistence() + const first = mockAdapter([[runStarted(), interruptFinished()]]) + await collect( + chat({ + adapter: first.adapter, + messages: [{ role: 'user', content: 'hi' }], + runId: 'r1', + threadId: 't1', + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + + const continuation = mockAdapter([[text('SHOULD NOT RUN')]]) + await expect( + collect( + chat({ + adapter: continuation.adapter, + messages: [], + runId: 'r1', + threadId: 't1', + resume: [], + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ), + ).rejects.toThrow(/pending interrupts.*resume is required/i) + + await expect( + collect( + chat({ + adapter: continuation.adapter, + messages: [], + runId: 'r1', + threadId: 't1', + resume: [{ interruptId: 'stale-interrupt', status: 'resolved' }], + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ), + ).rejects.toThrow(/missing resume entry.*interrupt-1/i) + + expect(continuation.calls).toHaveLength(0) + expect(await persistence.stores.interrupts!.listPending('t1')).toHaveLength( + 1, + ) + }) + + it('rejects stale resume entries when a thread has no pending interrupts', async () => { + const persistence = memoryPersistence() + const first = mockAdapter([[runStarted(), text('done'), runFinished()]]) + await collect( + chat({ + adapter: first.adapter, + messages: [{ role: 'user', content: 'hi' }], + runId: 'r1', + threadId: 't1', + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + expect(await persistence.stores.interrupts!.listPending('t1')).toEqual([]) + + const continuation = mockAdapter([[text('SHOULD NOT RUN')]]) + await expect( + collect( + chat({ + adapter: continuation.adapter, + messages: [], + runId: 'r1', + threadId: 't1', + resume: [{ interruptId: 'stale-interrupt', status: 'resolved' }], + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ), + ).rejects.toThrow(/non-pending interrupt stale-interrupt/i) + + expect(continuation.calls).toHaveLength(0) + }) + + it('accepts resume only when every pending interrupt has a valid matching entry', async () => { + const persistence = memoryPersistence() + await persistence.stores.interrupts!.create({ + interruptId: 'interrupt-1', + runId: 'old-run', + threadId: 't1', + requestedAt: 1, + payload: {}, + }) + const bad = mockAdapter([[runStarted(), interruptFinished()]]) + + await expect( + collect( + chat({ + adapter: bad.adapter, + messages: [{ role: 'user', content: 'new input' }], + runId: 'r2', + threadId: 't1', + resume: [{ interruptId: 'different', status: 'resolved' }], + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ), + ).rejects.toThrow(/missing resume entry.*interrupt-1/i) + + const good = mockAdapter([[runStarted(), interruptFinished('r2')]]) + await collect( + chat({ + adapter: good.adapter, + messages: [{ role: 'user', content: 'new input' }], + runId: 'r2', + threadId: 't1', + resume: [{ interruptId: 'interrupt-1', status: 'resolved' }], + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + + expect(good.calls).toHaveLength(1) + }) + + it('rejects extra stale resume entries when pending interrupts are satisfied', async () => { + const persistence = memoryPersistence() + await persistence.stores.interrupts!.create({ + interruptId: 'interrupt-1', + runId: 'old-run', + threadId: 't1', + requestedAt: 1, + payload: {}, + }) + const run = mockAdapter([[text('SHOULD NOT RUN')]]) + + await expect( + collect( + chat({ + adapter: run.adapter, + messages: [{ role: 'user', content: 'new input' }], + runId: 'r2', + threadId: 't1', + resume: [ + { interruptId: 'interrupt-1', status: 'resolved' }, + { interruptId: 'stale-interrupt', status: 'resolved' }, + ], + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ), + ).rejects.toThrow(/non-pending interrupt stale-interrupt/i) + + expect(run.calls).toHaveLength(0) + expect(await persistence.stores.interrupts!.listPending('t1')).toHaveLength( + 1, + ) + }) + + it('applies valid resume entries and allows later normal input', async () => { + const persistence = memoryPersistence() + await persistence.stores.interrupts!.create({ + interruptId: 'resolve-me', + runId: 'old-run', + threadId: 't1', + requestedAt: 1, + payload: {}, + }) + await persistence.stores.interrupts!.create({ + interruptId: 'cancel-me', + runId: 'old-run', + threadId: 't1', + requestedAt: 1, + payload: {}, + }) + + const resumeRun = mockAdapter([ + [runStarted(), text('ok'), runFinished('r2')], + ]) + await collect( + chat({ + adapter: resumeRun.adapter, + messages: [{ role: 'user', content: 'resume' }], + runId: 'r2', + threadId: 't1', + resume: [ + { + interruptId: 'resolve-me', + status: 'resolved', + payload: { approved: true }, + }, + { interruptId: 'cancel-me', status: 'cancelled' }, + ], + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + + expect(await persistence.stores.interrupts!.listPending('t1')).toEqual([]) + expect( + (await persistence.stores.interrupts!.get('resolve-me'))?.status, + ).toBe('resolved') + expect( + (await persistence.stores.interrupts!.get('resolve-me'))?.response, + ).toEqual({ approved: true }) + expect( + (await persistence.stores.interrupts!.get('cancel-me'))?.status, + ).toBe('cancelled') + + const nextRun = mockAdapter([ + [runStarted(), text('next'), runFinished('r3')], + ]) + await collect( + chat({ + adapter: nextRun.adapter, + messages: [{ role: 'user', content: 'next' }], + runId: 'r3', + threadId: 't1', + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + expect(nextRun.calls).toHaveLength(1) + }) + + it('marks terminal interrupt outcomes as interrupted', async () => { + const persistence = memoryPersistence() + const { adapter } = mockAdapter([[runStarted(), interruptFinished()]]) + + await collect( + chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + runId: 'r1', + threadId: 't1', + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + + expect((await persistence.stores.runs!.get('r1'))?.status).toBe( + 'interrupted', + ) + expect(await persistence.stores.interrupts!.listPending('t1')).toHaveLength( + 1, + ) + }) + + it('keeps the interrupt pending when a resume run fails, and a retry succeeds', async () => { + const persistence = memoryPersistence() + + // Run 1 pauses on an interrupt. + const first = mockAdapter([[runStarted(), interruptFinished()]]) + await collect( + chat({ + adapter: first.adapter, + messages: [{ role: 'user', content: 'hi' }], + runId: 'r1', + threadId: 't1', + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + expect(await persistence.stores.interrupts!.listPending('t1')).toHaveLength( + 1, + ) + + // Run 2 (the resume) accepts the approval, then the provider fails + // mid-stream (e.g. HTTP 500) before reaching any success boundary. + const failing = { + kind: 'text', + name: 'mock', + model: 'test-model', + '~types': {}, + chatStream: () => + (async function* () { + yield runStarted() + throw new Error('provider 500') + })(), + structuredOutput: async () => ({ data: {}, rawText: '{}' }), + } as unknown as AnyTextAdapter + + await expect( + collect( + chat({ + adapter: failing, + messages: [], + runId: 'r1', + threadId: 't1', + resume: [ + { + interruptId: 'interrupt-1', + status: 'resolved', + payload: { approved: true }, + }, + ], + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ), + ).rejects.toThrow('provider 500') + + // The approval was NOT consumed: the interrupt is pending again and the run + // is marked failed. + expect( + (await persistence.stores.interrupts!.get('interrupt-1'))?.status, + ).toBe('pending') + expect(await persistence.stores.interrupts!.listPending('t1')).toHaveLength( + 1, + ) + expect((await persistence.stores.runs!.get('r1'))?.status).toBe('failed') + + // Retrying with the same resume now succeeds and consumes the approval. + const retry = mockAdapter([ + [runStarted(), text('continued'), runFinished('r1')], + ]) + const chunks = await collect( + chat({ + adapter: retry.adapter, + messages: [], + runId: 'r1', + threadId: 't1', + resume: [ + { + interruptId: 'interrupt-1', + status: 'resolved', + payload: { approved: true }, + }, + ], + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + expect(chunks).toContainEqual( + expect.objectContaining({ delta: 'continued' }), + ) + expect(await persistence.stores.interrupts!.listPending('t1')).toEqual([]) + expect( + (await persistence.stores.interrupts!.get('interrupt-1'))?.status, + ).toBe('resolved') + }) + + it('fails closed: an approval resume without an `approved` flag denies the tool', async () => { + const persistence = memoryPersistence() + await persistence.stores.interrupts!.create({ + interruptId: 'approval-1', + runId: 'r1', + threadId: 't1', + requestedAt: 1, + payload: { toolCallId: 'tc1', metadata: { kind: 'approval' } }, + }) + + const run = mockAdapter([[runStarted(), text('ok'), runFinished('r1')]]) + await collect( + chat({ + adapter: run.adapter, + messages: [], + runId: 'r1', + threadId: 't1', + // Malformed/truncated persisted payload: no `approved` field. + resume: [ + { interruptId: 'approval-1', status: 'resolved', payload: {} }, + ], + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + + const approvals = ( + run.calls[0] as { approvals?: ReadonlyMap } + ).approvals + expect(approvals?.get('approval-1')).toBe(false) + }) + + it('honors an explicit approved:true resume payload', async () => { + const persistence = memoryPersistence() + await persistence.stores.interrupts!.create({ + interruptId: 'approval-1', + runId: 'r1', + threadId: 't1', + requestedAt: 1, + payload: { toolCallId: 'tc1', metadata: { kind: 'approval' } }, + }) + + const run = mockAdapter([[runStarted(), text('ok'), runFinished('r1')]]) + await collect( + chat({ + adapter: run.adapter, + messages: [], + runId: 'r1', + threadId: 't1', + resume: [ + { + interruptId: 'approval-1', + status: 'resolved', + payload: { approved: true }, + }, + ], + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + + const approvals = ( + run.calls[0] as { approvals?: ReadonlyMap } + ).approvals + expect(approvals?.get('approval-1')).toBe(true) + }) + + it('denies the tool when an approval is cancelled', async () => { + const persistence = memoryPersistence() + await persistence.stores.interrupts!.create({ + interruptId: 'approval-1', + runId: 'r1', + threadId: 't1', + requestedAt: 1, + payload: { toolCallId: 'tc1', metadata: { kind: 'approval' } }, + }) + + const run = mockAdapter([[runStarted(), text('ok'), runFinished('r1')]]) + await collect( + chat({ + adapter: run.adapter, + messages: [], + runId: 'r1', + threadId: 't1', + resume: [{ interruptId: 'approval-1', status: 'cancelled' }], + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + + const approvals = ( + run.calls[0] as { approvals?: ReadonlyMap } + ).approvals + expect(approvals?.get('approval-1')).toBe(false) + expect( + (await persistence.stores.interrupts!.get('approval-1'))?.status, + ).toBe('cancelled') + }) + + it('drops the result of a cancelled client-tool interrupt', async () => { + const persistence = memoryPersistence() + await persistence.stores.interrupts!.create({ + interruptId: 'client-1', + runId: 'r1', + threadId: 't1', + requestedAt: 1, + payload: { toolCallId: 'tc1', metadata: { kind: 'client_tool' } }, + }) + + const run = mockAdapter([[runStarted(), text('ok'), runFinished('r1')]]) + await collect( + chat({ + adapter: run.adapter, + messages: [], + runId: 'r1', + threadId: 't1', + resume: [ + { + interruptId: 'client-1', + status: 'cancelled', + payload: { answer: 99 }, + }, + ], + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + + // A cancelled client tool never surfaces its payload as a tool result: the + // resume state carries no entry for the tool call. + const clientToolResults = ( + run.calls[0] as { clientToolResults?: ReadonlyMap } + ).clientToolResults + expect(clientToolResults?.get('tc1')).toBeUndefined() + expect((await persistence.stores.interrupts!.get('client-1'))?.status).toBe( + 'cancelled', + ) + }) + + it('tolerates malformed persisted interrupt payloads without crashing', async () => { + const persistence = memoryPersistence() + // Payload with the wrong shapes for the defensive parsers: metadata is a + // string (not an object), toolCallId is a number. + await persistence.stores.interrupts!.create({ + interruptId: 'weird-1', + runId: 'r1', + threadId: 't1', + requestedAt: 1, + payload: { metadata: 'not-an-object', toolCallId: 123 }, + }) + + const run = mockAdapter([[runStarted(), text('ok'), runFinished('r1')]]) + await collect( + chat({ + adapter: run.adapter, + messages: [], + runId: 'r1', + threadId: 't1', + resume: [{ interruptId: 'weird-1', status: 'resolved', payload: {} }], + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + + // The run is unaffected (no approval/client-tool detected) and the resume is + // still committed on success. + expect(run.calls).toHaveLength(1) + expect( + (run.calls[0] as { approvals?: ReadonlyMap }).approvals + ?.size ?? 0, + ).toBe(0) + expect((await persistence.stores.interrupts!.get('weird-1'))?.status).toBe( + 'resolved', + ) + }) +}) diff --git a/packages/ai-persistence/tests/memory.conformance.test.ts b/packages/ai-persistence/tests/memory.conformance.test.ts new file mode 100644 index 000000000..0b382b6a0 --- /dev/null +++ b/packages/ai-persistence/tests/memory.conformance.test.ts @@ -0,0 +1,4 @@ +import { runPersistenceConformance } from '../src/testkit/conformance' +import { memoryPersistence } from '../src/memory' + +runPersistenceConformance('memory', () => memoryPersistence()) diff --git a/packages/ai-persistence/tests/memory.test.ts b/packages/ai-persistence/tests/memory.test.ts new file mode 100644 index 000000000..560069ecf --- /dev/null +++ b/packages/ai-persistence/tests/memory.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest' +import { memoryPersistence } from '../src/memory' +import { defineAIPersistence } from '../src/types' + +describe('memoryPersistence', () => { + it('returns a namespaced AIPersistence with every state store present', () => { + const p = memoryPersistence() + expect(p.stores.messages).toBeDefined() + expect(p.stores.runs).toBeDefined() + expect(p.stores.interrupts).toBeDefined() + expect(p.stores.locks).toBeDefined() + }) + + it('exposes the complete state store set', () => { + expect(Object.keys(memoryPersistence().stores).sort()).toEqual([ + 'interrupts', + 'locks', + 'messages', + 'metadata', + 'runs', + ]) + }) + + it('defineAIPersistence is an identity helper', () => { + const persistence = memoryPersistence() + expect(defineAIPersistence(persistence)).toBe(persistence) + }) + + describe('runs', () => { + it('createOrResume is idempotent and update patches status', async () => { + const { runs } = memoryPersistence().stores + const a = await runs!.createOrResume({ + runId: 'r1', + threadId: 't1', + startedAt: 100, + }) + expect(a.status).toBe('running') + // Resume returns the SAME record, not a fresh one. + const b = await runs!.createOrResume({ + runId: 'r1', + threadId: 't1', + startedAt: 999, + }) + expect(b.startedAt).toBe(100) + + await runs!.update('r1', { status: 'completed', finishedAt: 200 }) + const got = await runs!.get('r1') + expect(got?.status).toBe('completed') + expect(got?.finishedAt).toBe(200) + }) + }) + + describe('messages', () => { + it('round-trips a thread transcript', async () => { + const { messages } = memoryPersistence().stores + expect(await messages!.loadThread('t1')).toEqual([]) + await messages!.saveThread('t1', [{ role: 'user', content: 'hi' }]) + expect(await messages!.loadThread('t1')).toEqual([ + { role: 'user', content: 'hi' }, + ]) + }) + }) + + describe('interrupts', () => { + it('creates, resolves, and lists pending interrupts by thread', async () => { + const { interrupts } = memoryPersistence().stores + await interrupts!.create({ + interruptId: 'i1', + runId: 'r1', + threadId: 't1', + requestedAt: 1, + payload: { kind: 'approval' }, + }) + expect((await interrupts!.get('i1'))?.status).toBe('pending') + expect(await interrupts!.listPending('t1')).toHaveLength(1) + await interrupts!.resolve('i1', { action: 'approve' }) + expect((await interrupts!.get('i1'))?.status).toBe('resolved') + expect(await interrupts!.listPending('t1')).toHaveLength(0) + }) + + it('lists interrupts and pending interrupts by run', async () => { + const { interrupts } = memoryPersistence().stores + await interrupts!.create({ + interruptId: 'i1', + runId: 'r1', + threadId: 't1', + requestedAt: 1, + payload: { kind: 'approval' }, + }) + await interrupts!.create({ + interruptId: 'i2', + runId: 'r1', + threadId: 't2', + requestedAt: 2, + payload: { kind: 'input' }, + }) + await interrupts!.create({ + interruptId: 'i3', + runId: 'r2', + threadId: 't1', + requestedAt: 3, + payload: { kind: 'other' }, + }) + + await interrupts!.resolve('i2') + + expect( + (await interrupts!.listByRun('r1')).map( + (interrupt) => interrupt.interruptId, + ), + ).toEqual(['i1', 'i2']) + expect( + (await interrupts!.listPendingByRun('r1')).map( + (interrupt) => interrupt.interruptId, + ), + ).toEqual(['i1']) + }) + }) + + describe('metadata', () => { + it('returns null for missing metadata and preserves stored undefined', async () => { + const { metadata } = memoryPersistence().stores + expect(await metadata!.get('scope', 'missing')).toBeNull() + + await metadata!.set('scope', 'present', undefined) + expect(await metadata!.get('scope', 'present')).toBeUndefined() + + await metadata!.delete('scope', 'present') + expect(await metadata!.get('scope', 'present')).toBeNull() + }) + }) +}) diff --git a/packages/ai-persistence/tests/persistence-composition.test.ts b/packages/ai-persistence/tests/persistence-composition.test.ts new file mode 100644 index 000000000..07bf755bb --- /dev/null +++ b/packages/ai-persistence/tests/persistence-composition.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest' +import { composePersistence, defineAIPersistence } from '../src' +import { + createInterruptStore, + createMessageStore, + createMetadataStore, + createRunStore, +} from './persistence-fixtures' + +describe('composePersistence', () => { + it('replaces only the named store', () => { + const baseMessages = createMessageStore() + const overrideMessages = createMessageStore() + const runs = createRunStore() + const base = defineAIPersistence({ + stores: { messages: baseMessages, runs }, + }) + + const composed = composePersistence(base, { + overrides: { messages: overrideMessages }, + }) + + expect(composed.stores.messages).toBe(overrideMessages) + expect(composed.stores.runs).toBe(runs) + }) + + it('applies multiple overrides independently', () => { + const base = defineAIPersistence({ + stores: { + messages: createMessageStore(), + runs: createRunStore(), + interrupts: createInterruptStore(), + }, + }) + const messages = createMessageStore() + const runs = createRunStore() + + const composed = composePersistence(base, { + overrides: { messages, runs }, + }) + + expect(composed.stores.messages).toBe(messages) + expect(composed.stores.runs).toBe(runs) + expect(composed.stores.interrupts).toBe(base.stores.interrupts) + }) + + it('removes each store explicitly overridden with false', () => { + const base = defineAIPersistence({ + stores: { + messages: createMessageStore(), + runs: createRunStore(), + interrupts: createInterruptStore(), + }, + }) + + const composed = composePersistence(base, { + overrides: { runs: false, interrupts: false }, + }) + + expect('runs' in composed.stores).toBe(false) + expect('interrupts' in composed.stores).toBe(false) + expect(composed.stores.messages).toBe(base.stores.messages) + expect(base.stores.runs).toBeDefined() + expect(base.stores.interrupts).toBeDefined() + }) + + it('inherits omitted and explicitly undefined stores from the base', () => { + const messages = createMessageStore() + const runs = createRunStore() + const metadata = createMetadataStore() + const base = defineAIPersistence({ stores: { messages, runs, metadata } }) + + const composed = composePersistence(base, { + overrides: { messages: undefined, metadata: createMetadataStore() }, + }) + + expect(composed.stores.messages).toBe(messages) + expect(composed.stores.runs).toBe(runs) + expect(composed.stores.metadata).not.toBe(metadata) + }) + + it('does not mutate or assume ownership of base and override resources', () => { + let baseDisposeCalls = 0 + let overrideDisposeCalls = 0 + const baseMessages = { + ...createMessageStore(), + dispose: () => { + baseDisposeCalls += 1 + }, + } + const overrideMessages = { + ...createMessageStore(), + dispose: () => { + overrideDisposeCalls += 1 + }, + } + const base = defineAIPersistence({ + stores: { messages: baseMessages, runs: createRunStore() }, + }) + const overrides = { messages: overrideMessages } + Object.freeze(base.stores) + Object.freeze(overrides) + + const composed = composePersistence(base, { overrides }) + + expect(composed).not.toBe(base) + expect(composed.stores).not.toBe(base.stores) + expect(base.stores.messages).toBe(baseMessages) + expect(overrides.messages).toBe(overrideMessages) + expect(baseDisposeCalls).toBe(0) + expect(overrideDisposeCalls).toBe(0) + }) + + it('rejects unknown override keys received from untyped JavaScript', () => { + const base = defineAIPersistence({ + stores: { messages: createMessageStore() }, + }) + const overrides = { messages: createMessageStore() } + Reflect.set(overrides, 'unknownStore', createRunStore()) + + expect(() => composePersistence(base, { overrides })).toThrow( + /unknown.*unknownStore/i, + ) + }) + + it('rejects unknown base store keys received from untyped JavaScript', () => { + const stores = { messages: createMessageStore() } + Reflect.set(stores, 'unknownStore', createRunStore()) + + expect(() => defineAIPersistence({ stores })).toThrow( + /unknown.*unknownStore/i, + ) + }) + + it('routes calls only to the selected store', async () => { + const baseCalls: Array = [] + const overrideCalls: Array = [] + const base = defineAIPersistence({ + stores: { + messages: createMessageStore((threadId) => baseCalls.push(threadId)), + }, + }) + const overrideMessages = createMessageStore((threadId) => + overrideCalls.push(threadId), + ) + const composed = composePersistence(base, { + overrides: { messages: overrideMessages }, + }) + + await composed.stores.messages.saveThread('thread-1', []) + + expect(overrideCalls).toEqual(['thread-1']) + expect(baseCalls).toEqual([]) + }) +}) diff --git a/packages/ai-persistence/tests/persistence-fixtures.ts b/packages/ai-persistence/tests/persistence-fixtures.ts new file mode 100644 index 000000000..f86ec7ce5 --- /dev/null +++ b/packages/ai-persistence/tests/persistence-fixtures.ts @@ -0,0 +1,64 @@ +import type { + InterruptStore, + MessageStore, + MetadataStore, + RunRecord, + RunStore, +} from '../src' + +export function createMessageStore( + onSave?: (threadId: string) => void, +): MessageStore { + return { + loadThread: () => Promise.resolve([]), + saveThread: (threadId) => { + onSave?.(threadId) + return Promise.resolve() + }, + } +} + +export function createRunStore(): RunStore { + const runs = new Map() + return { + createOrResume: (input) => { + const existing = runs.get(input.runId) + if (existing) return Promise.resolve(existing) + const record: RunRecord = { + runId: input.runId, + threadId: input.threadId, + status: input.status ?? 'running', + startedAt: input.startedAt, + } + runs.set(record.runId, record) + return Promise.resolve(record) + }, + update: (runId, patch) => { + const existing = runs.get(runId) + if (existing) runs.set(runId, { ...existing, ...patch }) + return Promise.resolve() + }, + get: (runId) => Promise.resolve(runs.get(runId) ?? null), + } +} + +export function createInterruptStore(): InterruptStore { + return { + create: () => Promise.resolve(), + resolve: () => Promise.resolve(), + cancel: () => Promise.resolve(), + get: () => Promise.resolve(null), + list: () => Promise.resolve([]), + listPending: () => Promise.resolve([]), + listByRun: () => Promise.resolve([]), + listPendingByRun: () => Promise.resolve([]), + } +} + +export function createMetadataStore(): MetadataStore { + return { + get: () => Promise.resolve(null), + set: () => Promise.resolve(), + delete: () => Promise.resolve(), + } +} diff --git a/packages/ai-persistence/tests/persistence-types.test-d.ts b/packages/ai-persistence/tests/persistence-types.test-d.ts new file mode 100644 index 000000000..8c55d7abd --- /dev/null +++ b/packages/ai-persistence/tests/persistence-types.test-d.ts @@ -0,0 +1,140 @@ +import { expectTypeOf } from 'vitest' +import { + composePersistence, + defineAIPersistence, + memoryPersistence, + withPersistence, + withGenerationPersistence, +} from '../src' +import type { LockStore } from '../src/locks' +import type { + AIPersistence, + InterruptStore, + MessageStore, + MetadataStore, + RunStore, +} from '../src' + +declare const messages: MessageStore +declare const replacementMessages: MessageStore & { + readonly source: 'override-messages' +} +declare const runs: RunStore +declare const replacementRuns: RunStore & { + readonly source: 'override-runs' +} +declare const interrupts: InterruptStore +declare const metadata: MetadataStore +declare const locks: LockStore + +const messagesOnly = defineAIPersistence({ stores: { messages } }) +expectTypeOf(messagesOnly).toEqualTypeOf< + AIPersistence<{ messages: MessageStore }> +>() +expectTypeOf(messagesOnly.stores).toEqualTypeOf<{ + messages: MessageStore +}>() +// @ts-expect-error exact persistence types do not invent absent stores +messagesOnly.stores.runs + +// @ts-expect-error persistence aggregates accept only registered store keys +defineAIPersistence({ stores: { unknownStore: messages } }) + +type InvalidExplicitStores = { + messages: MessageStore + unknownStore: MessageStore +} +const invalidExplicitPersistence: AIPersistence = { + // @ts-expect-error explicit AIPersistence store maps are exact too + stores: { messages, unknownStore: messages }, +} +void invalidExplicitPersistence + +const base = defineAIPersistence({ + stores: { messages, runs, interrupts, metadata }, +}) + +const replaced = composePersistence(base, { + overrides: { messages: replacementMessages }, +}) +expectTypeOf(replaced.stores).toEqualTypeOf<{ + messages: typeof replacementMessages + runs: RunStore + interrupts: InterruptStore + metadata: MetadataStore +}>() + +const multiple = composePersistence(base, { + overrides: { messages: replacementMessages, runs: replacementRuns }, +}) +expectTypeOf(multiple.stores.messages).toEqualTypeOf< + typeof replacementMessages +>() +expectTypeOf(multiple.stores.runs).toEqualTypeOf() +expectTypeOf(multiple.stores.interrupts).toEqualTypeOf() + +// @ts-expect-error persistence overrides accept only registered store keys +composePersistence(base, { overrides: { unknownStore: messages } }) + +const removed = composePersistence(base, { + overrides: { runs: false, interrupts: false }, +}) +expectTypeOf(removed.stores).toEqualTypeOf<{ + messages: MessageStore + metadata: MetadataStore +}>() +// @ts-expect-error false removes the store from the exact result +removed.stores.runs +// @ts-expect-error false removes the store from the exact result +removed.stores.interrupts + +const inherited = composePersistence(base, { + overrides: { messages: undefined }, +}) +expectTypeOf(inherited.stores.messages).toEqualTypeOf() +expectTypeOf(inherited.stores.runs).toEqualTypeOf() + +declare const uncertainRemoval: MessageStore | false +const uncertain = composePersistence(base, { + overrides: { messages: uncertainRemoval }, +}) +expectTypeOf(uncertain.stores.messages).toEqualTypeOf< + MessageStore | undefined +>() +expectTypeOf(uncertain.stores.runs).toEqualTypeOf() + +declare const uncertainReplacement: MessageStore | undefined +const uncertainInherited = composePersistence(base, { + overrides: { messages: uncertainReplacement }, +}) +expectTypeOf(uncertainInherited.stores.messages).toEqualTypeOf() + +withPersistence(messagesOnly) +withPersistence(defineAIPersistence({ stores: { runs } })) +withPersistence(defineAIPersistence({ stores: { runs, interrupts, messages } })) +// @ts-expect-error a known interrupt store requires a known run store +withPersistence(defineAIPersistence({ stores: { interrupts } })) + +withGenerationPersistence(defineAIPersistence({ stores: { runs } })) + +const chatWithRemovedRuns = composePersistence(base, { + overrides: { runs: false }, +}) +// @ts-expect-error composition carries the missing run dependency into chat +withPersistence(chatWithRemovedRuns) + +declare const broadPersistence: AIPersistence +declare const dynamicChatPersistence: AIPersistence<{ + runs?: RunStore + interrupts?: InterruptStore +}> +withPersistence(broadPersistence) +withPersistence(dynamicChatPersistence) +withGenerationPersistence(broadPersistence) + +const memoryWithCustomLocks = composePersistence(memoryPersistence(), { + overrides: { locks }, +}) +expectTypeOf(memoryWithCustomLocks.stores.locks).toEqualTypeOf() +const mutableMemoryPersistence = memoryPersistence() +mutableMemoryPersistence.stores.locks = locks diff --git a/packages/ai-persistence/tests/persistence-validation.test.ts b/packages/ai-persistence/tests/persistence-validation.test.ts new file mode 100644 index 000000000..8a478abd0 --- /dev/null +++ b/packages/ai-persistence/tests/persistence-validation.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { withPersistence } from '../src/middleware' +import type { AIPersistence } from '../src' +import { + createInterruptStore, + createMessageStore, + createRunStore, +} from './persistence-fixtures' + +describe('persistence store dependency validation', () => { + it('rejects a dynamic chat persistence with interrupts but no runs', () => { + const persistence: AIPersistence = { + stores: { interrupts: createInterruptStore() }, + } + + expect(() => withPersistence(persistence)).toThrow( + /interrupts.*stores\.runs/i, + ) + }) + + it('allows independent message and run stores for chat', () => { + const messages: AIPersistence = { + stores: { messages: createMessageStore() }, + } + const runs: AIPersistence = { stores: { runs: createRunStore() } } + + expect(() => withPersistence(messages)).not.toThrow() + expect(() => withPersistence(runs)).not.toThrow() + }) + + it('allows a dynamic chat persistence with paired run and interrupt stores', () => { + const persistence: AIPersistence = { + stores: { + runs: createRunStore(), + interrupts: createInterruptStore(), + }, + } + + expect(() => withPersistence(persistence)).not.toThrow() + }) +}) diff --git a/packages/ai-persistence/tests/state-only.test.ts b/packages/ai-persistence/tests/state-only.test.ts new file mode 100644 index 000000000..72cf41003 --- /dev/null +++ b/packages/ai-persistence/tests/state-only.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import { EventType, chat } from '@tanstack/ai' +import type { AnyTextAdapter, StreamChunk } from '@tanstack/ai' +import { memoryPersistence } from '../src/memory' +import { withPersistence } from '../src/middleware' + +function mockAdapter(iterations: Array>) { + const calls: Array = [] + let i = 0 + const adapter = { + kind: 'text', + name: 'mock', + model: 'test-model', + '~types': {}, + chatStream: (opts: unknown) => { + calls.push(opts) + const chunks = iterations[i] ?? [] + i++ + return (async function* () { + for (const c of chunks) yield c + })() + }, + structuredOutput: async () => ({ data: {}, rawText: '{}' }), + } as unknown as AnyTextAdapter + return { adapter, calls } +} + +const script = (): Array> => [ + [ + { type: EventType.RUN_STARTED, runId: 'r1', threadId: 't1', timestamp: 1 }, + { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: 'm1', + delta: 'hello world', + timestamp: 1, + }, + { + type: EventType.RUN_FINISHED, + runId: 'r1', + threadId: 't1', + finishReason: 'stop', + timestamp: 1, + }, + ], +] + +async function collect(stream: AsyncIterable) { + const out: Array = [] + for await (const c of stream) out.push(c) + return out +} + +describe('state-only persistence', () => { + it('persists thread messages and run status', async () => { + const persistence = memoryPersistence() + const { adapter } = mockAdapter(script()) + + await collect( + chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + runId: 'r1', + threadId: 't1', + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + + expect((await persistence.stores.runs!.get('r1'))?.status).toBe('completed') + expect( + (await persistence.stores.messages!.loadThread('t1')).length, + ).toBeGreaterThan(0) + }) + + it('produces chunks byte-identical to a non-persisted run (no cursor)', async () => { + const persisted = await collect( + chat({ + adapter: mockAdapter(script()).adapter, + messages: [{ role: 'user', content: 'hi' }], + runId: 'r1', + threadId: 't1', + middleware: [withPersistence(memoryPersistence())], + }) as AsyncIterable, + ) + + const plain = await collect( + chat({ + adapter: mockAdapter(script()).adapter, + messages: [{ role: 'user', content: 'hi' }], + runId: 'r1', + threadId: 't1', + }) as AsyncIterable, + ) + + expect(JSON.stringify(persisted)).toEqual(JSON.stringify(plain)) + expect(persisted.every((c) => !('cursor' in c))).toBe(true) + }) +}) diff --git a/packages/ai-persistence/tests/with-persistence.test.ts b/packages/ai-persistence/tests/with-persistence.test.ts new file mode 100644 index 000000000..e5d80b47f --- /dev/null +++ b/packages/ai-persistence/tests/with-persistence.test.ts @@ -0,0 +1,384 @@ +import { describe, expect, it } from 'vitest' +import { EventType, chat } from '@tanstack/ai' +import type { AnyTextAdapter, StreamChunk } from '@tanstack/ai' +import { memoryPersistence } from '../src/memory' +import { withPersistence } from '../src/middleware' +import { defineAIPersistence } from '../src/types' + +// --- minimal mock text adapter --------------------------------------------- + +function mockAdapter(iterations: Array>) { + const calls: Array = [] + let i = 0 + const adapter = { + kind: 'text', + name: 'mock', + model: 'test-model', + '~types': {}, + chatStream: (opts: unknown) => { + calls.push(opts) + const chunks = iterations[i] ?? [] + i++ + return (async function* () { + for (const c of chunks) yield c + })() + }, + structuredOutput: async () => ({ data: {}, rawText: '{}' }), + } as unknown as AnyTextAdapter + return { adapter, calls } +} + +const ev = { + runStarted: (runId = 'r1', threadId = 't1'): StreamChunk => ({ + type: EventType.RUN_STARTED, + runId, + threadId, + timestamp: 1, + }), + text: (delta: string): StreamChunk => ({ + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: 'm1', + delta, + timestamp: 1, + }), + runFinished: (runId = 'r1', threadId = 't1'): StreamChunk => ({ + type: EventType.RUN_FINISHED, + runId, + threadId, + finishReason: 'stop', + timestamp: 1, + }), + interrupted: (interruptId = 'interrupt-1'): StreamChunk => ({ + type: EventType.RUN_FINISHED, + runId: 'r1', + threadId: 't1', + finishReason: 'stop', + timestamp: 1, + outcome: { + type: 'interrupt', + interrupts: [ + { + id: interruptId, + reason: 'approval_required', + toolCallId: 'tool-1', + metadata: { kind: 'approval' }, + }, + ], + }, + }), +} + +async function collect(stream: AsyncIterable) { + const out: Array = [] + for await (const c of stream) out.push(c) + return out +} + +async function expectCollectRejects( + stream: AsyncIterable, + pattern: RegExp, +) { + await expect(collect(stream)).rejects.toThrow(pattern) +} + +describe('withPersistence (state-only)', () => { + it('completes the run and saves the transcript', async () => { + const persistence = memoryPersistence() + const { adapter } = mockAdapter([ + [ev.runStarted(), ev.text('hello'), ev.runFinished()], + ]) + + const chunks = await collect( + chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + runId: 'r1', + threadId: 't1', + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + + // The persistence middleware never stamps delivery cursors on the stream. + expect(chunks.length).toBeGreaterThan(0) + expect(chunks.every((c) => !('cursor' in c))).toBe(true) + + // Run is completed and the FULL transcript is saved — including the + // assistant's terminal text reply, which the engine does not append to the + // middleware message list itself (see `finishedTranscript`). + expect((await persistence.stores.runs!.get('r1'))?.status).toBe('completed') + expect(await persistence.stores.messages!.loadThread('t1')).toEqual([ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: 'hello' }, + ]) + }) + + it('persists the pending user turn at start, so it survives a failed run', async () => { + const persistence = memoryPersistence() + const adapter = { + kind: 'text', + name: 'mock', + model: 'test-model', + '~types': {}, + chatStream: () => + (async function* () { + yield ev.runStarted() + throw new Error('provider boom') + })(), + structuredOutput: async () => ({ data: {}, rawText: '{}' }), + } as unknown as AnyTextAdapter + + await expect( + collect( + chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + runId: 'r1', + threadId: 't1', + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ), + ).rejects.toThrow('provider boom') + + // onStart persisted the user turn before the failure, so it is not lost. + expect(await persistence.stores.messages!.loadThread('t1')).toEqual([ + { role: 'user', content: 'hi' }, + ]) + expect((await persistence.stores.runs!.get('r1'))?.status).toBe('failed') + }) + + it('snapshots the in-progress reply when snapshotStreaming is on', async () => { + const persistence = memoryPersistence() + const adapter = { + kind: 'text', + name: 'mock', + model: 'test-model', + '~types': {}, + chatStream: () => + (async function* () { + yield ev.runStarted() + yield { + type: EventType.TEXT_MESSAGE_START, + messageId: 'm1', + timestamp: 1, + } + yield ev.text('Half a stor') + // Die mid-generation, before any RUN_FINISHED / onFinish. + throw new Error('crash mid-stream') + })(), + structuredOutput: async () => ({ data: {}, rawText: '{}' }), + } as unknown as AnyTextAdapter + + await expect( + collect( + chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + runId: 'r1', + threadId: 't1', + middleware: [ + withPersistence(persistence, { snapshotStreaming: true }), + ], + }) as AsyncIterable, + ), + ).rejects.toThrow('crash mid-stream') + + // The partial assistant reply was snapshotted mid-stream, so it survives — + // tagged with its stream messageId so a reload resumes the same bubble. + expect(await persistence.stores.messages!.loadThread('t1')).toEqual([ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: 'Half a stor', id: 'm1' }, + ]) + }) + + it('stamps the terminal assistant turn with its stream messageId', async () => { + const persistence = memoryPersistence() + const { adapter } = mockAdapter([ + [ + ev.runStarted(), + { + type: EventType.TEXT_MESSAGE_START, + messageId: 'assistant-42', + timestamp: 1, + }, + ev.text('hello'), + ev.runFinished(), + ], + ]) + + await collect( + chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + runId: 'r1', + threadId: 't1', + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + + // Identity round-trip: the persisted assistant turn keeps the stream id, so + // `modelMessagesToUIMessages` reuses it and a reload can resume in place. + expect(await persistence.stores.messages!.loadThread('t1')).toEqual([ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: 'hello', id: 'assistant-42' }, + ]) + }) + + it('records an interrupt and marks the run interrupted', async () => { + const persistence = memoryPersistence() + const { adapter } = mockAdapter([[ev.runStarted(), ev.interrupted()]]) + + await collect( + chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + runId: 'r1', + threadId: 't1', + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + + expect((await persistence.stores.runs!.get('r1'))?.status).toBe( + 'interrupted', + ) + expect(await persistence.stores.interrupts!.listPending('t1')).toHaveLength( + 1, + ) + }) + + it('blocks normal new input while a thread has pending interrupts', async () => { + const persistence = memoryPersistence() + const first = mockAdapter([[ev.runStarted(), ev.interrupted()]]) + + await collect( + chat({ + adapter: first.adapter, + messages: [{ role: 'user', content: 'hi' }], + runId: 'r1', + threadId: 't1', + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + + const next = mockAdapter([[ev.text('SHOULD NOT RUN')]]) + await expectCollectRejects( + chat({ + adapter: next.adapter, + messages: [{ role: 'user', content: 'new input' }], + runId: 'r2', + threadId: 't1', + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + /pending interrupts.*resume is required/i, + ) + expect(next.calls.length).toBe(0) + }) + + it('requires resume entries to match all pending interrupts', async () => { + const persistence = memoryPersistence() + const first = mockAdapter([[ev.runStarted(), ev.interrupted()]]) + + await collect( + chat({ + adapter: first.adapter, + messages: [{ role: 'user', content: 'hi' }], + runId: 'r1', + threadId: 't1', + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + + const next = mockAdapter([[ev.text('SHOULD NOT RUN')]]) + await expectCollectRejects( + chat({ + adapter: next.adapter, + messages: [{ role: 'user', content: 'new input' }], + runId: 'r2', + threadId: 't1', + resume: [{ interruptId: 'other-interrupt', status: 'resolved' }], + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + /missing resume entry for pending interrupt interrupt-1/i, + ) + expect(next.calls.length).toBe(0) + }) + + it('applies matching resume entries and then allows new input', async () => { + const persistence = memoryPersistence() + const first = mockAdapter([[ev.runStarted(), ev.interrupted()]]) + + await collect( + chat({ + adapter: first.adapter, + messages: [{ role: 'user', content: 'hi' }], + runId: 'r1', + threadId: 't1', + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + + const next = mockAdapter([[ev.runStarted('r2', 't1'), ev.text('fresh')]]) + const chunks = await collect( + chat({ + adapter: next.adapter, + messages: [{ role: 'user', content: 'new input' }], + runId: 'r2', + threadId: 't1', + // The engine requires the interrupted run's id to correlate the resume. + parentRunId: 'r1', + resume: [ + { + interruptId: 'interrupt-1', + status: 'resolved', + payload: { approved: true }, + }, + ], + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + + expect(next.calls.length).toBe(1) + expect( + chunks.some((chunk) => chunk.type === EventType.TEXT_MESSAGE_CONTENT), + ).toBe(true) + expect( + (await persistence.stores.interrupts!.get('interrupt-1'))?.status, + ).toBe('resolved') + }) + + it('persists messages without requiring a run store', async () => { + const full = memoryPersistence() + const persistence = defineAIPersistence({ + stores: { messages: full.stores.messages }, + }) + const { adapter } = mockAdapter([ + [ev.runStarted(), ev.text('hello'), ev.runFinished()], + ]) + + await collect( + chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + runId: 'r1', + threadId: 't1', + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + + expect(await persistence.stores.messages!.loadThread('t1')).not.toEqual([]) + }) + + it('is a no-op without the middleware: the stream is unchanged', async () => { + const { adapter } = mockAdapter([ + [ev.runStarted(), ev.text('plain'), ev.runFinished()], + ]) + const chunks = await collect( + chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + runId: 'r1', + threadId: 't1', + }) as AsyncIterable, + ) + expect(chunks.every((c) => !('cursor' in c))).toBe(true) + }) +}) diff --git a/packages/ai-persistence/tsconfig.json b/packages/ai-persistence/tsconfig.json new file mode 100644 index 000000000..c38689f4e --- /dev/null +++ b/packages/ai-persistence/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ai-persistence/vite.config.ts b/packages/ai-persistence/vite.config.ts new file mode 100644 index 000000000..95f0f3d72 --- /dev/null +++ b/packages/ai-persistence/vite.config.ts @@ -0,0 +1,41 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + '**/types.ts', + ], + include: ['src/**/*.ts'], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts', './src/testkit/conformance.ts'], + srcDir: './src', + // The conformance testkit imports Vitest; keep it external so the built + // artifact references the consumer's Vitest at test time instead of + // bundling the runner. + externalDeps: ['vitest'], + cjs: false, + }), +) diff --git a/packages/ai-preact/src/index.ts b/packages/ai-preact/src/index.ts index cd3ca7b39..6be181f42 100644 --- a/packages/ai-preact/src/index.ts +++ b/packages/ai-preact/src/index.ts @@ -16,6 +16,17 @@ export type { export { fetchServerSentEvents, + localStoragePersistence, + sessionStoragePersistence, + indexedDBPersistence, + StorageUnavailableError, + type ChatClientPersistence, + type ChatPersistedState, + type ChatPersistenceConfig, + type ChatPersistenceOption, + type ChatStorageAdapter, + type WebStoragePersistenceOptions, + type IndexedDBPersistenceOptions, fetchHttpStream, xhrServerSentEvents, xhrHttpStream, diff --git a/packages/ai-react/src/index.ts b/packages/ai-react/src/index.ts index 773c05456..de33077c0 100644 --- a/packages/ai-react/src/index.ts +++ b/packages/ai-react/src/index.ts @@ -69,6 +69,17 @@ export type { // Re-export from ai-client for convenience export { fetchServerSentEvents, + localStoragePersistence, + sessionStoragePersistence, + indexedDBPersistence, + StorageUnavailableError, + type ChatClientPersistence, + type ChatPersistedState, + type ChatPersistenceConfig, + type ChatPersistenceOption, + type ChatStorageAdapter, + type WebStoragePersistenceOptions, + type IndexedDBPersistenceOptions, fetchHttpStream, xhrServerSentEvents, xhrHttpStream, diff --git a/packages/ai-solid/src/index.ts b/packages/ai-solid/src/index.ts index 1d0c517b3..e433084e3 100644 --- a/packages/ai-solid/src/index.ts +++ b/packages/ai-solid/src/index.ts @@ -62,6 +62,17 @@ export type { // Re-export from ai-client for convenience export { fetchServerSentEvents, + localStoragePersistence, + sessionStoragePersistence, + indexedDBPersistence, + StorageUnavailableError, + type ChatClientPersistence, + type ChatPersistedState, + type ChatPersistenceConfig, + type ChatPersistenceOption, + type ChatStorageAdapter, + type WebStoragePersistenceOptions, + type IndexedDBPersistenceOptions, fetchHttpStream, xhrServerSentEvents, xhrHttpStream, diff --git a/packages/ai-svelte/src/index.ts b/packages/ai-svelte/src/index.ts index 151fdab5d..dca735476 100644 --- a/packages/ai-svelte/src/index.ts +++ b/packages/ai-svelte/src/index.ts @@ -65,6 +65,17 @@ export type { // Re-export from ai-client for convenience export { fetchServerSentEvents, + localStoragePersistence, + sessionStoragePersistence, + indexedDBPersistence, + StorageUnavailableError, + type ChatClientPersistence, + type ChatPersistedState, + type ChatPersistenceConfig, + type ChatPersistenceOption, + type ChatStorageAdapter, + type WebStoragePersistenceOptions, + type IndexedDBPersistenceOptions, fetchHttpStream, xhrServerSentEvents, xhrHttpStream, diff --git a/packages/ai-vue/src/index.ts b/packages/ai-vue/src/index.ts index 1d0c517b3..e433084e3 100644 --- a/packages/ai-vue/src/index.ts +++ b/packages/ai-vue/src/index.ts @@ -62,6 +62,17 @@ export type { // Re-export from ai-client for convenience export { fetchServerSentEvents, + localStoragePersistence, + sessionStoragePersistence, + indexedDBPersistence, + StorageUnavailableError, + type ChatClientPersistence, + type ChatPersistedState, + type ChatPersistenceConfig, + type ChatPersistenceOption, + type ChatStorageAdapter, + type WebStoragePersistenceOptions, + type IndexedDBPersistenceOptions, fetchHttpStream, xhrServerSentEvents, xhrHttpStream, diff --git a/packages/ai/skills/ai-core/chat-experience/SKILL.md b/packages/ai/skills/ai-core/chat-experience/SKILL.md index b6a114345..137dab6f8 100644 --- a/packages/ai/skills/ai-core/chat-experience/SKILL.md +++ b/packages/ai/skills/ai-core/chat-experience/SKILL.md @@ -17,6 +17,8 @@ sources: - 'TanStack/ai:docs/chat/thinking-content.md' - 'TanStack/ai:docs/advanced/multimodal-content.md' - 'TanStack/ai:docs/resumable-streams/overview.md' + - 'TanStack/ai:docs/chat/persistence.md' + - 'TanStack/ai:docs/persistence/client-persistence.md' --- # Chat Experience @@ -485,6 +487,75 @@ to `sendMessage`: sendMessage('Never mind, do this instead', { whenBusy: 'interrupt' }) ``` +### 8. Browser-Refresh Durability (client persistence) + +By default a `ChatClient` / `useChat` keeps messages in memory only, so a full +page reload loses the conversation. The optional `persistence` option (a +`ChatClientPersistence` adapter) fixes this from the client side: it stores one +combined record — `{ messages, resume? }` (`ChatPersistedState`) — per chat `id`, +so a reload restores the transcript **and** rehydrates any pending interrupt / +rejoins a run that was still streaming. No manual `initialMessages` + `onFinish` +boilerplate. + +Three storage adapters ship from `@tanstack/ai-client`: +`localStoragePersistence` (survives reloads and browser restarts), +`sessionStoragePersistence` (scoped to the tab), and `indexedDBPersistence` +(async, structured-clone storage — no codec needed for `Date`/`Map`/etc.). +Give the chat a stable `threadId` so the reload finds the same record. +Persistence keys on `threadId`; the storage adapters are re-exported from each +framework package, so a single import works: + +```typescript +import { + useChat, + fetchServerSentEvents, + localStoragePersistence, +} from '@tanstack/ai-react' + +// Defaults to the ChatPersistedState shape and a JSON codec, so no type +// argument or serialize/deserialize is needed. indexedDBPersistence stores via +// structured clone (a Date round-trips exactly). +const persistence = localStoragePersistence() + +function Chat() { + const { messages, sendMessage } = useChat({ + threadId: 'support-chat', + connection: fetchServerSentEvents('/api/chat'), + persistence, + }) + // ...render messages, call sendMessage(text) +} +``` + +**Keep large transcripts off the client.** `persistence` also accepts the object +form `{ store, messages?: boolean }`. `messages: false` caches only the tiny +resume pointer (which run to rejoin, which interrupts are pending), so durability +rejoin and interrupt restore still work while the transcript stays off the client +and the server stays authoritative for history. A bare adapter is shorthand for +`{ store, messages: true }`. With `messages: false`, hydrate the reload display +from the server (a loader that reads `messages.loadThread(id)`), since the +delivery log only holds one run. + +**Mid-stream reload rejoin.** If the run was still streaming when the page +reloaded, the client re-attaches instead of showing a frozen half-reply — but +only when the connection is **resumable**: a delivery-durability-backed route +that records the stream and exposes a GET replay handler (see +`docs/resumable-streams/overview.md` and Pattern 1's `durability` adapter). Given +that, `useChat` finds the persisted in-flight run on load and auto-rejoins it via +`joinRun`, replaying from the server's log so the reply finishes where it left +off. No extra client code beyond the resumable connection. + +**Every framework, no extra code.** Durability rides the existing `persistence` +option, so it works identically in `@tanstack/ai-react`, `-solid`, `-vue`, +`-svelte`, `-angular`, and `-preact` — pass `persistence` (and a stable `id`) to +the framework's `useChat` / `createChat` / `injectChat`; nothing is +framework-specific. + +> **Client vs. server durability.** This is the client (per-browser) half. +> The authoritative, multi-user, server-side copy is the `withPersistence` +> middleware — see ai-core/middleware/SKILL.md. The two are independent; use +> both for instant reload restore plus a durable record of record. + ## Common Mistakes ### a. CRITICAL: Using Vercel AI SDK patterns (streamText, generateText) @@ -695,4 +766,4 @@ If not handled, the UI appears to hang with no feedback. - See also: **ai-core/tool-calling/SKILL.md** -- Most chats include tools - See also: **ai-core/adapter-configuration/SKILL.md** -- Adapter choice affects available features -- See also: **ai-core/middleware/SKILL.md** -- Use middleware for analytics and lifecycle events +- See also: **ai-core/middleware/SKILL.md** -- Use middleware for analytics and lifecycle events; `withPersistence` is the server (authoritative) half of the client `persistence` option diff --git a/packages/ai/skills/ai-core/middleware/SKILL.md b/packages/ai/skills/ai-core/middleware/SKILL.md index 65c6bf7b8..ca0747b74 100644 --- a/packages/ai/skills/ai-core/middleware/SKILL.md +++ b/packages/ai/skills/ai-core/middleware/SKILL.md @@ -12,6 +12,7 @@ library_version: '0.10.0' sources: - 'TanStack/ai:docs/advanced/middleware.md' - 'TanStack/ai:docs/sandbox/observability.md' + - 'TanStack/ai:docs/persistence/overview.md' --- # Middleware @@ -372,6 +373,94 @@ Options: `maxSize` (default 100), `ttl` (default Infinity), `toolNames` (default `keyFn` (custom cache key), `storage` (custom backend like Redis). See `docs/advanced/middleware.md` for custom storage examples. +## Server State Persistence: withPersistence + +`withPersistence(persistence)` (from `@tanstack/ai-persistence`) is a +`ChatMiddleware` that persists **state** for `chat()` — thread messages, run +records (status/timing/usage/errors), and interrupt state — to a backend store. +Add it to the `middleware` array like any other middleware. It never mutates the +chunk stream; replaying a dropped/reloaded _stream_ is a separate transport-layer +concern (see ai-core/chat-experience/SKILL.md resumability, not this middleware). + +```typescript +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { withPersistence } from '@tanstack/ai-persistence' +import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite' + +const persistence = sqlitePersistence({ + url: 'file:.tanstack-ai/state.sqlite', + migrate: true, +}) + +export async function POST(request: Request) { + const params = await chatParamsFromRequest(request) + + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages: params.messages, + threadId: params.threadId, + runId: params.runId, + ...(params.resume ? { resume: params.resume } : {}), + middleware: [withPersistence(persistence)], + }) + + return toServerSentEventsResponse(stream) +} +``` + +### Authoritative-history contract + +The middleware treats each request's `messages` as the source of truth for the +thread: + +- **Non-empty `messages`** → on a successful finish (and at an interrupt + boundary) the middleware **overwrites** the entire stored thread with that + array. Post the **complete** transcript, never just the newest message(s) — a + delta would replace and destroy the stored history. +- **Empty `messages`** → the middleware **loads** the stored thread and runs the + turn from the server's copy. This is how you continue a conversation without + resending history from the client. + +### Backends + +Every backend returns an `AIPersistence` you pass straight to +`withPersistence`: + +| Backend | Factory | Import | +| -------------------------------------- | ----------------------------------------------- | ----------------------------------------- | +| In-memory (dev/tests) | `memoryPersistence()` | `@tanstack/ai-persistence` | +| Drizzle SQLite-family (edge-safe) | `drizzlePersistence(db)` | `@tanstack/ai-persistence-drizzle` | +| Node SQLite convenience factory | `sqlitePersistence({ url, migrate })` | `@tanstack/ai-persistence-drizzle/sqlite` | +| Prisma | `prismaPersistence(prisma)` | `@tanstack/ai-persistence-prisma` | +| Cloudflare (D1 + Durable Object locks) | `cloudflarePersistence({ d1, durableObjects })` | `@tanstack/ai-persistence-cloudflare` | + +`drizzlePersistence(db)` is the edge-safe root — pass an already-migrated +SQLite-compatible Drizzle database (including Cloudflare D1). `sqlitePersistence` +is Node-only and lives at the `/sqlite` subpath. Compose backends per store with +`composePersistence(base, { overrides })`. + +### Resume reconstruction is the engine's job, not the middleware's + +When a thread has pending interrupts, the middleware only **records** the +interrupts and **gates** new input: a request that carries pending interrupts +must include a `resume` batch that references them, or `onConfig` throws. The +middleware does **not** build `ChatResumeToolState`. The chat engine reconstructs +the actual resume tool state itself, from `config.resume` plus the interrupt +bindings carried in the server-loaded message history. Resumes accepted in +`onConfig` are committed (marked resolved/cancelled) only once the run reaches a +successful boundary, so a provider failure between accepting a resume and +finishing leaves the interrupt pending and a retry with the same resume succeeds. + +> A companion `withGenerationPersistence(persistence)` tracks run records for +> non-chat generation activities (image, audio, TTS, video, transcription). + +Source: docs/persistence/overview.md + ## Sandbox File-Event Hooks (`sandbox` group) Declare a `sandbox: ChatSandboxHooks` group on `defineChatMiddleware` to react @@ -557,3 +646,4 @@ Source: docs/advanced/middleware.md - See also: **ai-core/chat-experience/SKILL.md** -- Middleware hooks into the chat lifecycle - See also: **ai-core/structured-outputs/SKILL.md** -- Middleware now wraps the final structured-output call; use `onStructuredOutputConfig` for JSON-Schema transforms - See also: **ai-core/ag-ui-protocol/SKILL.md** -- Reading the `sandbox.file` / `sandbox.file.diff` `CUSTOM` chunks the sandbox runtime emits alongside these `sandbox` hooks, via `ChatStream`'s typed `KnownCustomEvent` narrowing +- See also: **ai-core/chat-experience/SKILL.md** -- `withPersistence` is the server (authoritative) half; the client `persistence` option is the client-persistence half diff --git a/packages/ai/src/activities/chat/messages.ts b/packages/ai/src/activities/chat/messages.ts index e83078842..87dbb17f1 100644 --- a/packages/ai/src/activities/chat/messages.ts +++ b/packages/ai/src/activities/chat/messages.ts @@ -615,12 +615,13 @@ export function modelMessagesToUIMessages( }) } else { // No assistant message to merge into, create a standalone one - const toolResultUIMessage = modelMessageToUIMessage(msg) + const toolResultUIMessage = modelMessageToUIMessage(msg, msg.id) uiMessages.push(toolResultUIMessage) } } else { - // Regular message - const uiMessage = modelMessageToUIMessage(msg) + // Regular message. Preserve a persisted stable id so a hydrated message + // keeps the same identity as its live stream (enables in-place resume). + const uiMessage = modelMessageToUIMessage(msg, msg.id) uiMessages.push(uiMessage) // Track assistant messages for potential tool result merging diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index e4e6d7cda..6fea4bac8 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -367,6 +367,15 @@ export interface ModelMessage< toolCalls?: Array toolCallId?: string thinking?: Array<{ content: string; signature?: string }> + /** + * Optional stable message id. Providers ignore it; it exists so a persisted + * transcript can retain the streaming `messageId` and survive the + * persist → hydrate round-trip. When present, `modelMessagesToUIMessages` + * reuses it instead of generating a fresh id, so a hydrated message keeps the + * same identity as its live stream — which is what lets a mid-stream reload + * resume the SAME message bubble in place (see `@tanstack/ai-persistence`). + */ + id?: string } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9812eaf84..63d35dbed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -357,7 +357,7 @@ importers: version: 17.2.3 nitro: specifier: 3.0.260610-beta - version: 3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0) + version: 3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0) tailwindcss: specifier: ^4.1.18 version: 4.1.18 @@ -514,7 +514,7 @@ importers: version: 15.0.12 nitro: specifier: 3.0.260610-beta - version: 3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0) + version: 3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0) puppeteer: specifier: ^24.34.0 version: 24.39.1(supports-color@7.2.0)(typescript@5.9.3) @@ -608,7 +608,7 @@ importers: version: 0.10.0 nitro: specifier: 3.0.260610-beta - version: 3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0) + version: 3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0) react: specifier: ^19.2.3 version: 19.2.3 @@ -742,6 +742,12 @@ importers: '@tanstack/ai-openrouter': specifier: workspace:* version: link:../../packages/ai-openrouter + '@tanstack/ai-persistence': + specifier: workspace:* + version: link:../../packages/ai-persistence + '@tanstack/ai-persistence-drizzle': + specifier: workspace:* + version: link:../../packages/ai-persistence-drizzle '@tanstack/ai-react': specifier: workspace:* version: link:../../packages/ai-react @@ -798,7 +804,7 @@ importers: version: 0.561.0(react@19.2.3) nitro: specifier: 3.0.260610-beta - version: 3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0) + version: 3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0) react: specifier: ^19.2.3 version: 19.2.3 @@ -901,7 +907,7 @@ importers: version: 0.561.0(react@19.2.3) nitro: specifier: 3.0.260610-beta - version: 3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0) + version: 3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0) react: specifier: ^19.2.3 version: 19.2.3 @@ -1035,7 +1041,7 @@ importers: version: 0.561.0(react@19.2.3) nitro: specifier: 3.0.260610-beta - version: 3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0) + version: 3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0) radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -1132,7 +1138,7 @@ importers: version: link:../../packages/ai-solid-ui '@tanstack/nitro-v2-vite-plugin': specifier: ^1.155.0 - version: 1.155.0(aws4fetch@1.0.20)(rolldown@1.1.5)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 1.155.0(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3)))(rolldown@1.1.5)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: ^1.158.4 version: 1.159.5(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) @@ -2043,6 +2049,78 @@ importers: specifier: ^4.2.0 version: 4.3.6 + packages/ai-persistence: + devDependencies: + '@tanstack/ai': + specifier: workspace:* + version: link:../ai + '@vitest/coverage-v8': + specifier: 4.0.14 + version: 4.0.14(supports-color@7.2.0)(vitest@4.1.10) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.0.14)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.19))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) + + packages/ai-persistence-cloudflare: + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20260317.1 + version: 4.20260317.1 + '@tanstack/ai': + specifier: workspace:* + version: link:../ai + '@tanstack/ai-persistence': + specifier: workspace:* + version: link:../ai-persistence + '@tanstack/ai-persistence-drizzle': + specifier: workspace:* + version: link:../ai-persistence-drizzle + '@vitest/coverage-v8': + specifier: 4.0.14 + version: 4.0.14(supports-color@7.2.0)(vitest@4.1.10) + drizzle-orm: + specifier: ^0.45.0 + version: 0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@7.0.2))(typescript@7.0.2))(prisma@6.19.3(typescript@7.0.2)) + miniflare: + specifier: ^4.20260609.0 + version: 4.20260617.1 + + packages/ai-persistence-drizzle: + devDependencies: + '@tanstack/ai': + specifier: workspace:* + version: link:../ai + '@tanstack/ai-persistence': + specifier: workspace:* + version: link:../ai-persistence + '@vitest/coverage-v8': + specifier: 4.0.14 + version: 4.0.14(supports-color@7.2.0)(vitest@4.1.10) + drizzle-kit: + specifier: ^0.31.0 + version: 0.31.10 + drizzle-orm: + specifier: ^0.45.0 + version: 0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@7.0.2))(typescript@7.0.2))(prisma@6.19.3(typescript@7.0.2)) + + packages/ai-persistence-prisma: + devDependencies: + '@prisma/client': + specifier: ^6.19.3 + version: 6.19.3(prisma@6.19.3(typescript@7.0.2))(typescript@7.0.2) + '@tanstack/ai': + specifier: workspace:* + version: link:../ai + '@tanstack/ai-persistence': + specifier: workspace:* + version: link:../ai-persistence + '@vitest/coverage-v8': + specifier: 4.0.14 + version: 4.0.14(supports-color@7.2.0)(vitest@4.1.10) + prisma: + specifier: ^6.19.3 + version: 6.19.3(typescript@7.0.2) + packages/ai-preact: dependencies: '@tanstack/ai-client': @@ -2607,7 +2685,7 @@ importers: version: 0.4.1 '@tanstack/nitro-v2-vite-plugin': specifier: ^1.155.0 - version: 1.155.0(aws4fetch@1.0.20)(rolldown@1.1.5)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 1.155.0(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(rolldown@1.1.5)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) '@tanstack/react-ai-devtools': specifier: workspace:* version: link:../../packages/react-ai-devtools @@ -2716,7 +2794,7 @@ importers: version: link:../../packages/ai-react-ui '@tanstack/nitro-v2-vite-plugin': specifier: ^1.155.0 - version: 1.155.0(aws4fetch@1.0.20)(rolldown@1.1.5)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 1.155.0(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(rolldown@1.1.5)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) '@tanstack/react-router': specifier: ^1.158.4 version: 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -2725,7 +2803,7 @@ importers: version: 1.159.5(crossws@0.4.6(srvx@0.11.17))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) '@tanstack/start': specifier: ^1.120.20 - version: 1.120.20(@types/node@24.10.3)(aws4fetch@1.0.20)(crossws@0.4.6(srvx@0.11.17))(db0@0.3.4)(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0) + version: 1.120.20(@types/node@24.10.3)(aws4fetch@1.0.20)(crossws@0.4.6(srvx@0.11.17))(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0) highlight.js: specifier: ^11.11.1 version: 11.11.1 @@ -3917,6 +3995,9 @@ packages: '@deno/shim-deno@0.19.2': resolution: {integrity: sha512-q3VTHl44ad8T2Tw2SpeAvghdGOjlnLPDNO2cpOxwMrBE/PVas6geWpbpIgrM+czOCH0yejp0yi8OaTuB+NU40Q==} + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + '@elevenlabs/client@1.3.1': resolution: {integrity: sha512-bQUxA/X7TZRSSZ6UM6a6A+1qQy5Wh7vMn+zbZP6Yl1WrupxHL4M0XMnl/n9+fsol1Ib4tN/2Nhx1E5JDS7QdKw==} @@ -3951,6 +4032,14 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + '@esbuild/aix-ppc64@0.20.2': resolution: {integrity: sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==} engines: {node: '>=12'} @@ -3981,6 +4070,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.20.2': resolution: {integrity: sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==} engines: {node: '>=12'} @@ -4011,6 +4106,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.20.2': resolution: {integrity: sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==} engines: {node: '>=12'} @@ -4041,6 +4142,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.20.2': resolution: {integrity: sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==} engines: {node: '>=12'} @@ -4071,6 +4178,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.20.2': resolution: {integrity: sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==} engines: {node: '>=12'} @@ -4101,6 +4214,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.20.2': resolution: {integrity: sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==} engines: {node: '>=12'} @@ -4131,6 +4250,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.20.2': resolution: {integrity: sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==} engines: {node: '>=12'} @@ -4161,6 +4286,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.20.2': resolution: {integrity: sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==} engines: {node: '>=12'} @@ -4191,6 +4322,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.20.2': resolution: {integrity: sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==} engines: {node: '>=12'} @@ -4221,6 +4358,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.20.2': resolution: {integrity: sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==} engines: {node: '>=12'} @@ -4251,6 +4394,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.20.2': resolution: {integrity: sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==} engines: {node: '>=12'} @@ -4281,6 +4430,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.20.2': resolution: {integrity: sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==} engines: {node: '>=12'} @@ -4311,6 +4466,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.20.2': resolution: {integrity: sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==} engines: {node: '>=12'} @@ -4341,6 +4502,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.20.2': resolution: {integrity: sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==} engines: {node: '>=12'} @@ -4371,6 +4538,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.20.2': resolution: {integrity: sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==} engines: {node: '>=12'} @@ -4401,6 +4574,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.20.2': resolution: {integrity: sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==} engines: {node: '>=12'} @@ -4431,6 +4610,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.20.2': resolution: {integrity: sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==} engines: {node: '>=12'} @@ -4485,6 +4670,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.20.2': resolution: {integrity: sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==} engines: {node: '>=12'} @@ -4539,6 +4730,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.20.2': resolution: {integrity: sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==} engines: {node: '>=12'} @@ -4593,6 +4790,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.20.2': resolution: {integrity: sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==} engines: {node: '>=12'} @@ -4623,6 +4826,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.20.2': resolution: {integrity: sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==} engines: {node: '>=12'} @@ -4653,6 +4862,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.20.2': resolution: {integrity: sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==} engines: {node: '>=12'} @@ -4683,6 +4898,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.20.2': resolution: {integrity: sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==} engines: {node: '>=12'} @@ -6665,6 +6886,36 @@ packages: '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@prisma/client@6.19.3': + resolution: {integrity: sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==} + engines: {node: '>=18.18'} + peerDependencies: + prisma: '*' + typescript: '>=5.1.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@6.19.3': + resolution: {integrity: sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==} + + '@prisma/debug@6.19.3': + resolution: {integrity: sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==} + + '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': + resolution: {integrity: sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==} + + '@prisma/engines@6.19.3': + resolution: {integrity: sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==} + + '@prisma/fetch-engine@6.19.3': + resolution: {integrity: sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==} + + '@prisma/get-platform@6.19.3': + resolution: {integrity: sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -10083,6 +10334,14 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + c12@3.1.0: + resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} + peerDependencies: + magicast: ^0.3.5 + peerDependenciesMeta: + magicast: + optional: true + c12@3.3.3: resolution: {integrity: sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==} peerDependencies: @@ -10643,6 +10902,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -10783,6 +11046,102 @@ packages: resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==} engines: {node: '>=10'} + drizzle-kit@0.31.10: + resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} + hasBin: true + + drizzle-orm@0.45.2: + resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + dts-resolver@2.1.3: resolution: {integrity: sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw==} engines: {node: '>=20.19.0'} @@ -10813,6 +11172,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + effect@3.21.0: + resolution: {integrity: sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==} + ejs@5.0.1: resolution: {integrity: sha512-COqBPFMxuPTPspXl2DkVYaDS3HtrD1GpzOGkNTJ1IYkifq/r9h8SVEFrjA3D9/VJGOEoMQcrlhpntcSUrM8k6A==} engines: {node: '>=0.12.18'} @@ -10938,6 +11300,11 @@ packages: esbuild: '>=0.12' solid-js: '>= 1.0' + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.20.2: resolution: {integrity: sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==} engines: {node: '>=12'} @@ -11219,6 +11586,10 @@ packages: engines: {node: '>= 10.17.0'} hasBin: true + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -13604,6 +13975,9 @@ packages: pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + perfect-debounce@2.0.0: resolution: {integrity: sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==} @@ -13753,6 +14127,16 @@ packages: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + prisma@6.19.3: + resolution: {integrity: sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==} + engines: {node: '>=18.18'} + hasBin: true + peerDependencies: + typescript: '>=5.1.0' + peerDependenciesMeta: + typescript: + optional: true + proc-log@4.2.0: resolution: {integrity: sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -13838,6 +14222,9 @@ packages: engines: {node: '>=18'} hasBin: true + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + qs@6.14.0: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} @@ -17927,6 +18314,8 @@ snapshots: '@deno/shim-deno-test': 0.5.0 which: 4.0.0 + '@drizzle-team/brocli@0.10.2': {} + '@elevenlabs/client@1.3.1(@types/dom-mediacapture-record@1.0.22)': dependencies: '@elevenlabs/types': 0.9.1 @@ -17983,6 +18372,16 @@ snapshots: dependencies: tslib: 2.8.1 + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.14.0 + '@esbuild/aix-ppc64@0.20.2': optional: true @@ -17998,6 +18397,9 @@ snapshots: '@esbuild/aix-ppc64@0.28.1': optional: true + '@esbuild/android-arm64@0.18.20': + optional: true + '@esbuild/android-arm64@0.20.2': optional: true @@ -18013,6 +18415,9 @@ snapshots: '@esbuild/android-arm64@0.28.1': optional: true + '@esbuild/android-arm@0.18.20': + optional: true + '@esbuild/android-arm@0.20.2': optional: true @@ -18028,6 +18433,9 @@ snapshots: '@esbuild/android-arm@0.28.1': optional: true + '@esbuild/android-x64@0.18.20': + optional: true + '@esbuild/android-x64@0.20.2': optional: true @@ -18043,6 +18451,9 @@ snapshots: '@esbuild/android-x64@0.28.1': optional: true + '@esbuild/darwin-arm64@0.18.20': + optional: true + '@esbuild/darwin-arm64@0.20.2': optional: true @@ -18058,6 +18469,9 @@ snapshots: '@esbuild/darwin-arm64@0.28.1': optional: true + '@esbuild/darwin-x64@0.18.20': + optional: true + '@esbuild/darwin-x64@0.20.2': optional: true @@ -18073,6 +18487,9 @@ snapshots: '@esbuild/darwin-x64@0.28.1': optional: true + '@esbuild/freebsd-arm64@0.18.20': + optional: true + '@esbuild/freebsd-arm64@0.20.2': optional: true @@ -18088,6 +18505,9 @@ snapshots: '@esbuild/freebsd-arm64@0.28.1': optional: true + '@esbuild/freebsd-x64@0.18.20': + optional: true + '@esbuild/freebsd-x64@0.20.2': optional: true @@ -18103,6 +18523,9 @@ snapshots: '@esbuild/freebsd-x64@0.28.1': optional: true + '@esbuild/linux-arm64@0.18.20': + optional: true + '@esbuild/linux-arm64@0.20.2': optional: true @@ -18118,6 +18541,9 @@ snapshots: '@esbuild/linux-arm64@0.28.1': optional: true + '@esbuild/linux-arm@0.18.20': + optional: true + '@esbuild/linux-arm@0.20.2': optional: true @@ -18133,6 +18559,9 @@ snapshots: '@esbuild/linux-arm@0.28.1': optional: true + '@esbuild/linux-ia32@0.18.20': + optional: true + '@esbuild/linux-ia32@0.20.2': optional: true @@ -18148,6 +18577,9 @@ snapshots: '@esbuild/linux-ia32@0.28.1': optional: true + '@esbuild/linux-loong64@0.18.20': + optional: true + '@esbuild/linux-loong64@0.20.2': optional: true @@ -18163,6 +18595,9 @@ snapshots: '@esbuild/linux-loong64@0.28.1': optional: true + '@esbuild/linux-mips64el@0.18.20': + optional: true + '@esbuild/linux-mips64el@0.20.2': optional: true @@ -18178,6 +18613,9 @@ snapshots: '@esbuild/linux-mips64el@0.28.1': optional: true + '@esbuild/linux-ppc64@0.18.20': + optional: true + '@esbuild/linux-ppc64@0.20.2': optional: true @@ -18193,6 +18631,9 @@ snapshots: '@esbuild/linux-ppc64@0.28.1': optional: true + '@esbuild/linux-riscv64@0.18.20': + optional: true + '@esbuild/linux-riscv64@0.20.2': optional: true @@ -18208,6 +18649,9 @@ snapshots: '@esbuild/linux-riscv64@0.28.1': optional: true + '@esbuild/linux-s390x@0.18.20': + optional: true + '@esbuild/linux-s390x@0.20.2': optional: true @@ -18223,6 +18667,9 @@ snapshots: '@esbuild/linux-s390x@0.28.1': optional: true + '@esbuild/linux-x64@0.18.20': + optional: true + '@esbuild/linux-x64@0.20.2': optional: true @@ -18250,6 +18697,9 @@ snapshots: '@esbuild/netbsd-arm64@0.28.1': optional: true + '@esbuild/netbsd-x64@0.18.20': + optional: true + '@esbuild/netbsd-x64@0.20.2': optional: true @@ -18277,6 +18727,9 @@ snapshots: '@esbuild/openbsd-arm64@0.28.1': optional: true + '@esbuild/openbsd-x64@0.18.20': + optional: true + '@esbuild/openbsd-x64@0.20.2': optional: true @@ -18304,6 +18757,9 @@ snapshots: '@esbuild/openharmony-arm64@0.28.1': optional: true + '@esbuild/sunos-x64@0.18.20': + optional: true + '@esbuild/sunos-x64@0.20.2': optional: true @@ -18319,6 +18775,9 @@ snapshots: '@esbuild/sunos-x64@0.28.1': optional: true + '@esbuild/win32-arm64@0.18.20': + optional: true + '@esbuild/win32-arm64@0.20.2': optional: true @@ -18334,6 +18793,9 @@ snapshots: '@esbuild/win32-arm64@0.28.1': optional: true + '@esbuild/win32-ia32@0.18.20': + optional: true + '@esbuild/win32-ia32@0.20.2': optional: true @@ -18349,6 +18811,9 @@ snapshots: '@esbuild/win32-ia32@0.28.1': optional: true + '@esbuild/win32-x64@0.18.20': + optional: true + '@esbuild/win32-x64@0.20.2': optional: true @@ -20306,6 +20771,47 @@ snapshots: '@poppinss/exception@1.2.3': {} + '@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)': + optionalDependencies: + prisma: 6.19.3(magicast@0.5.2)(typescript@5.9.3) + typescript: 5.9.3 + optional: true + + '@prisma/client@6.19.3(prisma@6.19.3(typescript@7.0.2))(typescript@7.0.2)': + optionalDependencies: + prisma: 6.19.3(typescript@7.0.2) + typescript: 7.0.2 + + '@prisma/config@6.19.3(magicast@0.5.2)': + dependencies: + c12: 3.1.0(magicast@0.5.2) + deepmerge-ts: 7.1.5 + effect: 3.21.0 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + '@prisma/debug@6.19.3': {} + + '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': {} + + '@prisma/engines@6.19.3': + dependencies: + '@prisma/debug': 6.19.3 + '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 + '@prisma/fetch-engine': 6.19.3 + '@prisma/get-platform': 6.19.3 + + '@prisma/fetch-engine@6.19.3': + dependencies: + '@prisma/debug': 6.19.3 + '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 + '@prisma/get-platform': 6.19.3 + + '@prisma/get-platform@6.19.3': + dependencies: + '@prisma/debug': 6.19.3 + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -22142,9 +22648,45 @@ snapshots: '@tanstack/history@1.154.14': {} - '@tanstack/nitro-v2-vite-plugin@1.155.0(aws4fetch@1.0.20)(rolldown@1.1.5)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))': + '@tanstack/nitro-v2-vite-plugin@1.155.0(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3)))(rolldown@1.1.5)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))': dependencies: - nitropack: 2.13.1(aws4fetch@1.0.20)(rolldown@1.1.5) + nitropack: 2.13.1(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3)))(rolldown@1.1.5) + pathe: 2.0.3 + vite: 8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bare-abort-controller + - better-sqlite3 + - drizzle-orm + - encoding + - idb-keyval + - mysql2 + - react-native-b4a + - rolldown + - sqlite3 + - supports-color + - uploadthing + - xml2js + + '@tanstack/nitro-v2-vite-plugin@1.155.0(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(rolldown@1.1.5)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))': + dependencies: + nitropack: 2.13.1(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(rolldown@1.1.5) pathe: 2.0.3 vite: 8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: @@ -22279,9 +22821,9 @@ snapshots: tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/react-start-plugin@1.131.50(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@vitejs/plugin-react@4.7.0(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(aws4fetch@1.0.20)(rolldown@1.1.5)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))': + '@tanstack/react-start-plugin@1.131.50(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@vitejs/plugin-react@4.7.0(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(rolldown@1.1.5)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))': dependencies: - '@tanstack/start-plugin-core': 1.131.50(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(aws4fetch@1.0.20)(rolldown@1.1.5)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) + '@tanstack/start-plugin-core': 1.131.50(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(rolldown@1.1.5)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) '@vitejs/plugin-react': 4.7.0(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) pathe: 2.0.3 vite: 8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) @@ -22321,11 +22863,11 @@ snapshots: - webpack - xml2js - '@tanstack/react-start-router-manifest@1.120.19(@types/node@24.10.3)(aws4fetch@1.0.20)(db0@0.3.4)(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)': + '@tanstack/react-start-router-manifest@1.120.19(@types/node@24.10.3)(aws4fetch@1.0.20)(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)': dependencies: '@tanstack/router-core': 1.157.16 tiny-invariant: 1.3.3 - vinxi: 0.5.3(@types/node@24.10.3)(aws4fetch@1.0.20)(db0@0.3.4)(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) + vinxi: 0.5.3(@types/node@24.10.3)(aws4fetch@1.0.20)(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -22806,11 +23348,11 @@ snapshots: '@tanstack/store': 0.8.0 solid-js: 1.9.10 - '@tanstack/start-api-routes@1.120.19(@types/node@24.10.3)(aws4fetch@1.0.20)(crossws@0.4.6(srvx@0.11.17))(db0@0.3.4)(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)': + '@tanstack/start-api-routes@1.120.19(@types/node@24.10.3)(aws4fetch@1.0.20)(crossws@0.4.6(srvx@0.11.17))(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)': dependencies: '@tanstack/router-core': 1.157.16 '@tanstack/start-server-core': 1.141.1(crossws@0.4.6(srvx@0.11.17)) - vinxi: 0.5.3(@types/node@24.10.3)(aws4fetch@1.0.20)(db0@0.3.4)(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) + vinxi: 0.5.3(@types/node@24.10.3)(aws4fetch@1.0.20)(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -22882,21 +23424,21 @@ snapshots: tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/start-config@1.120.20(@types/node@24.10.3)(aws4fetch@1.0.20)(crossws@0.4.6(srvx@0.11.17))(db0@0.3.4)(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0)': + '@tanstack/start-config@1.120.20(@types/node@24.10.3)(aws4fetch@1.0.20)(crossws@0.4.6(srvx@0.11.17))(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0)': dependencies: '@tanstack/react-router': 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/react-start-plugin': 1.131.50(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@vitejs/plugin-react@4.7.0(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(aws4fetch@1.0.20)(rolldown@1.1.5)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) + '@tanstack/react-start-plugin': 1.131.50(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@vitejs/plugin-react@4.7.0(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(rolldown@1.1.5)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) '@tanstack/router-generator': 1.141.1 '@tanstack/router-plugin': 1.159.5(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) '@tanstack/server-functions-plugin': 1.141.0(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) '@tanstack/start-server-functions-handler': 1.120.19(crossws@0.4.6(srvx@0.11.17)) '@vitejs/plugin-react': 4.7.0(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) import-meta-resolve: 4.2.0 - nitropack: 2.13.1(aws4fetch@1.0.20)(rolldown@1.1.5) + nitropack: 2.13.1(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(rolldown@1.1.5) ofetch: 1.5.1 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - vinxi: 0.5.3(@types/node@24.10.3)(aws4fetch@1.0.20)(db0@0.3.4)(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) + vinxi: 0.5.3(@types/node@24.10.3)(aws4fetch@1.0.20)(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) vite: 8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) zod: 3.25.76 transitivePeerDependencies: @@ -22950,7 +23492,7 @@ snapshots: '@tanstack/start-fn-stubs@1.154.7': {} - '@tanstack/start-plugin-core@1.131.50(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(aws4fetch@1.0.20)(rolldown@1.1.5)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))': + '@tanstack/start-plugin-core@1.131.50(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(rolldown@1.1.5)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@babel/code-frame': 7.26.2 '@babel/core': 7.29.0 @@ -22966,7 +23508,7 @@ snapshots: babel-dead-code-elimination: 1.0.10 cheerio: 1.1.2 h3: 1.13.0 - nitropack: 2.13.1(aws4fetch@1.0.20)(rolldown@1.1.5) + nitropack: 2.13.1(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(rolldown@1.1.5) pathe: 2.0.3 ufo: 1.6.3 vite: 8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) @@ -23190,13 +23732,13 @@ snapshots: dependencies: '@tanstack/router-core': 1.159.4 - '@tanstack/start@1.120.20(@types/node@24.10.3)(aws4fetch@1.0.20)(crossws@0.4.6(srvx@0.11.17))(db0@0.3.4)(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0)': + '@tanstack/start@1.120.20(@types/node@24.10.3)(aws4fetch@1.0.20)(crossws@0.4.6(srvx@0.11.17))(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0)': dependencies: '@tanstack/react-start-client': 1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/react-start-router-manifest': 1.120.19(@types/node@24.10.3)(aws4fetch@1.0.20)(db0@0.3.4)(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) + '@tanstack/react-start-router-manifest': 1.120.19(@types/node@24.10.3)(aws4fetch@1.0.20)(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) '@tanstack/react-start-server': 1.141.1(crossws@0.4.6(srvx@0.11.17))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/start-api-routes': 1.120.19(@types/node@24.10.3)(aws4fetch@1.0.20)(crossws@0.4.6(srvx@0.11.17))(db0@0.3.4)(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) - '@tanstack/start-config': 1.120.20(@types/node@24.10.3)(aws4fetch@1.0.20)(crossws@0.4.6(srvx@0.11.17))(db0@0.3.4)(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0) + '@tanstack/start-api-routes': 1.120.19(@types/node@24.10.3)(aws4fetch@1.0.20)(crossws@0.4.6(srvx@0.11.17))(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) + '@tanstack/start-config': 1.120.20(@types/node@24.10.3)(aws4fetch@1.0.20)(crossws@0.4.6(srvx@0.11.17))(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0) '@tanstack/start-server-functions-client': 1.131.50(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) '@tanstack/start-server-functions-handler': 1.120.19(crossws@0.4.6(srvx@0.11.17)) '@tanstack/start-server-functions-server': 1.131.2(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) @@ -24707,6 +25249,23 @@ snapshots: bytes@3.1.2: {} + c12@3.1.0(magicast@0.5.2): + dependencies: + chokidar: 4.0.3 + confbox: 0.2.2 + defu: 6.1.4 + dotenv: 16.6.1 + exsolve: 1.0.8 + giget: 2.0.0 + jiti: 2.7.0 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 1.0.0 + pkg-types: 2.3.0 + rc9: 2.1.2 + optionalDependencies: + magicast: 0.5.2 + c12@3.3.3(magicast@0.5.2): dependencies: chokidar: 5.0.0 @@ -25194,7 +25753,13 @@ snapshots: dayjs@1.11.19: {} - db0@0.3.4: {} + db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))): + optionalDependencies: + drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3)) + + db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))): + optionalDependencies: + drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)) de-indent@1.0.2: {} @@ -25245,6 +25810,8 @@ snapshots: deep-is@0.1.4: {} + deepmerge-ts@7.1.5: {} + deepmerge@4.3.1: {} defaults@1.0.4: @@ -25373,6 +25940,36 @@ snapshots: dotenv@8.6.0: {} + drizzle-kit@0.31.10: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + tsx: 4.21.0 + + drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3)): + optionalDependencies: + '@cloudflare/workers-types': 4.20260317.1 + '@opentelemetry/api': 1.9.1 + '@prisma/client': 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) + prisma: 6.19.3(magicast@0.5.2)(typescript@5.9.3) + optional: true + + drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)): + optionalDependencies: + '@cloudflare/workers-types': 4.20260317.1 + '@opentelemetry/api': 1.9.1 + '@prisma/client': 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) + prisma: 6.19.3(magicast@0.5.2)(typescript@5.9.3) + optional: true + + drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@7.0.2))(typescript@7.0.2))(prisma@6.19.3(typescript@7.0.2)): + optionalDependencies: + '@cloudflare/workers-types': 4.20260317.1 + '@opentelemetry/api': 1.9.1 + '@prisma/client': 6.19.3(prisma@6.19.3(typescript@7.0.2))(typescript@7.0.2) + prisma: 6.19.3(typescript@7.0.2) + dts-resolver@2.1.3(oxc-resolver@11.21.3): optionalDependencies: oxc-resolver: 11.21.3 @@ -25400,6 +25997,11 @@ snapshots: ee-first@1.1.1: {} + effect@3.21.0: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + ejs@5.0.1: {} electron-to-chromium@1.5.267: {} @@ -25513,6 +26115,31 @@ snapshots: transitivePeerDependencies: - supports-color + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + esbuild@0.20.2: optionalDependencies: '@esbuild/aix-ppc64': 0.20.2 @@ -26060,6 +26687,10 @@ snapshots: transitivePeerDependencies: - supports-color + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + fast-deep-equal@3.1.3: {} fast-equals@5.4.0: {} @@ -28399,11 +29030,11 @@ snapshots: rollup: 4.60.1 tailwindcss: 4.1.18 - nitro@3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0): + nitro@3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0): dependencies: consola: 3.4.2 crossws: 0.4.6(srvx@0.11.17) - db0: 0.3.4 + db0: 0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))) env-runner: 0.1.14(miniflare@4.20260617.1)(wrangler@4.103.0) h3: 2.0.1-rc.22(crossws@0.4.6(srvx@0.11.17)) hookable: 6.1.1 @@ -28414,7 +29045,7 @@ snapshots: rolldown: 1.1.5 srvx: 0.11.17 unenv: 2.0.0-rc.24 - unstorage: 2.0.0-alpha.7(aws4fetch@1.0.20)(chokidar@5.0.0)(db0@0.3.4)(ofetch@2.0.0-alpha.3) + unstorage: 2.0.0-alpha.7(aws4fetch@1.0.20)(chokidar@5.0.0)(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(ofetch@2.0.0-alpha.3) optionalDependencies: dotenv: 17.2.3 giget: 2.0.0 @@ -28453,11 +29084,11 @@ snapshots: - uploadthing - wrangler - nitro@3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0): + nitro@3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0): dependencies: consola: 3.4.2 crossws: 0.4.6(srvx@0.11.17) - db0: 0.3.4 + db0: 0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))) env-runner: 0.1.14(miniflare@4.20260617.1)(wrangler@4.103.0) h3: 2.0.1-rc.22(crossws@0.4.6(srvx@0.11.17)) hookable: 6.1.1 @@ -28468,7 +29099,7 @@ snapshots: rolldown: 1.1.5 srvx: 0.11.17 unenv: 2.0.0-rc.24 - unstorage: 2.0.0-alpha.7(aws4fetch@1.0.20)(chokidar@5.0.0)(db0@0.3.4)(ofetch@2.0.0-alpha.3) + unstorage: 2.0.0-alpha.7(aws4fetch@1.0.20)(chokidar@5.0.0)(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(ofetch@2.0.0-alpha.3) optionalDependencies: dotenv: 17.2.3 giget: 2.0.0 @@ -28507,7 +29138,7 @@ snapshots: - uploadthing - wrangler - nitropack@2.13.1(aws4fetch@1.0.20)(rolldown@1.1.5): + nitropack@2.13.1(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3)))(rolldown@1.1.5): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 '@rollup/plugin-alias': 6.0.0(rollup@4.60.1) @@ -28528,7 +29159,7 @@ snapshots: cookie-es: 2.0.1 croner: 9.1.0 crossws: 0.3.5 - db0: 0.3.4 + db0: 0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))) defu: 6.1.4 destr: 2.0.5 dot-prop: 10.1.0 @@ -28574,7 +29205,109 @@ snapshots: unenv: 2.0.0-rc.24 unimport: 5.6.0 unplugin-utils: 0.3.1 - unstorage: 1.17.4(aws4fetch@1.0.20)(db0@0.3.4)(ioredis@5.9.2) + unstorage: 1.17.4(aws4fetch@1.0.20)(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))))(ioredis@5.9.2) + untyped: 2.0.0 + unwasm: 0.5.3 + youch: 4.1.0-beta.13 + youch-core: 0.3.3 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bare-abort-controller + - better-sqlite3 + - drizzle-orm + - encoding + - idb-keyval + - mysql2 + - react-native-b4a + - rolldown + - sqlite3 + - supports-color + - uploadthing + + nitropack@2.13.1(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(rolldown@1.1.5): + dependencies: + '@cloudflare/kv-asset-handler': 0.4.2 + '@rollup/plugin-alias': 6.0.0(rollup@4.60.1) + '@rollup/plugin-commonjs': 29.0.0(rollup@4.60.1) + '@rollup/plugin-inject': 5.0.5(rollup@4.60.1) + '@rollup/plugin-json': 6.1.0(rollup@4.60.1) + '@rollup/plugin-node-resolve': 16.0.3(rollup@4.60.1) + '@rollup/plugin-replace': 6.0.3(rollup@4.60.1) + '@rollup/plugin-terser': 0.4.4(rollup@4.60.1) + '@vercel/nft': 1.3.0(rollup@4.60.1) + archiver: 7.0.1 + c12: 3.3.3(magicast@0.5.2) + chokidar: 5.0.0 + citty: 0.1.6 + compatx: 0.2.0 + confbox: 0.2.2 + consola: 3.4.2 + cookie-es: 2.0.1 + croner: 9.1.0 + crossws: 0.3.5 + db0: 0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))) + defu: 6.1.4 + destr: 2.0.5 + dot-prop: 10.1.0 + esbuild: 0.27.7 + escape-string-regexp: 5.0.0 + etag: 1.8.1 + exsolve: 1.0.8 + globby: 16.1.0 + gzip-size: 7.0.0 + h3: 1.15.5 + hookable: 5.5.3 + httpxy: 0.1.7 + ioredis: 5.9.2 + jiti: 2.7.0 + klona: 2.0.6 + knitwork: 1.3.0 + listhen: 1.9.0 + magic-string: 0.30.21 + magicast: 0.5.2 + mime: 4.1.0 + mlly: 1.8.0 + node-fetch-native: 1.6.7 + node-mock-http: 1.0.4 + ofetch: 1.5.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.0.0 + pkg-types: 2.3.0 + pretty-bytes: 7.1.0 + radix3: 1.1.2 + rollup: 4.60.1 + rollup-plugin-visualizer: 6.0.5(rolldown@1.1.5)(rollup@4.60.1) + scule: 1.3.0 + semver: 7.8.4 + serve-placeholder: 2.0.2 + serve-static: 2.2.1 + source-map: 0.7.6 + std-env: 3.10.0 + ufo: 1.6.3 + ultrahtml: 1.6.0 + uncrypto: 0.1.3 + unctx: 2.5.0 + unenv: 2.0.0-rc.24 + unimport: 5.6.0 + unplugin-utils: 0.3.1 + unstorage: 1.17.4(aws4fetch@1.0.20)(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(ioredis@5.9.2) untyped: 2.0.0 unwasm: 0.5.3 youch: 4.1.0-beta.13 @@ -29240,6 +29973,8 @@ snapshots: pend@1.2.0: {} + perfect-debounce@1.0.0: {} + perfect-debounce@2.0.0: {} picocolors@1.1.1: {} @@ -29353,6 +30088,25 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3): + dependencies: + '@prisma/config': 6.19.3(magicast@0.5.2) + '@prisma/engines': 6.19.3 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - magicast + optional: true + + prisma@6.19.3(typescript@7.0.2): + dependencies: + '@prisma/config': 6.19.3(magicast@0.5.2) + '@prisma/engines': 6.19.3 + optionalDependencies: + typescript: 7.0.2 + transitivePeerDependencies: + - magicast + proc-log@4.2.0: {} process-nextick-args@2.0.1: {} @@ -29486,6 +30240,8 @@ snapshots: - typescript - utf-8-validate + pure-rand@6.1.0: {} + qs@6.14.0: dependencies: side-channel: 1.1.0 @@ -31324,7 +32080,22 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' - unstorage@1.17.4(aws4fetch@1.0.20)(db0@0.3.4)(ioredis@5.9.2): + unstorage@1.17.4(aws4fetch@1.0.20)(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))))(ioredis@5.9.2): + dependencies: + anymatch: 3.1.3 + chokidar: 5.0.0 + destr: 2.0.5 + h3: 1.15.5 + lru-cache: 11.2.4 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + ufo: 1.6.3 + optionalDependencies: + aws4fetch: 1.0.20 + db0: 0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))) + ioredis: 5.9.2 + + unstorage@1.17.4(aws4fetch@1.0.20)(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(ioredis@5.9.2): dependencies: anymatch: 3.1.3 chokidar: 5.0.0 @@ -31336,14 +32107,14 @@ snapshots: ufo: 1.6.3 optionalDependencies: aws4fetch: 1.0.20 - db0: 0.3.4 + db0: 0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))) ioredis: 5.9.2 - unstorage@2.0.0-alpha.7(aws4fetch@1.0.20)(chokidar@5.0.0)(db0@0.3.4)(ofetch@2.0.0-alpha.3): + unstorage@2.0.0-alpha.7(aws4fetch@1.0.20)(chokidar@5.0.0)(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(ofetch@2.0.0-alpha.3): optionalDependencies: aws4fetch: 1.0.20 chokidar: 5.0.0 - db0: 0.3.4 + db0: 0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))) ofetch: 2.0.0-alpha.3 untun@0.1.3: @@ -31444,7 +32215,7 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vinxi@0.5.3(@types/node@24.10.3)(aws4fetch@1.0.20)(db0@0.3.4)(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0): + vinxi@0.5.3(@types/node@24.10.3)(aws4fetch@1.0.20)(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0): dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.0) @@ -31466,7 +32237,7 @@ snapshots: hookable: 5.5.3 http-proxy: 1.18.1 micromatch: 4.0.8 - nitropack: 2.13.1(aws4fetch@1.0.20)(rolldown@1.1.5) + nitropack: 2.13.1(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(rolldown@1.1.5) node-fetch-native: 1.6.7 path-to-regexp: 6.3.0 pathe: 1.1.2 @@ -31477,7 +32248,7 @@ snapshots: ufo: 1.6.1 unctx: 2.4.1 unenv: 1.10.0 - unstorage: 1.17.4(aws4fetch@1.0.20)(db0@0.3.4)(ioredis@5.9.2) + unstorage: 1.17.4(aws4fetch@1.0.20)(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(ioredis@5.9.2) vite: 6.4.2(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) zod: 3.25.76 transitivePeerDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d0616db5d..6a2b290b4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -95,3 +95,9 @@ allowBuilds: # JS fallback, so their native builds are not required. cpu-features: false ssh2: false + # @tanstack/ai-persistence-prisma runs `prisma generate` (nx `db:generate`) + # as a build/typecheck prerequisite, so the Prisma engine + client build + # scripts must run. + '@prisma/client': true + '@prisma/engines': true + prisma: true diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 8687d013b..b9d592506 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as ToolsTestRouteImport } from './routes/tools-test' +import { Route as PersistenceDurabilityRouteImport } from './routes/persistence-durability' import { Route as MiddlewareTestRouteImport } from './routes/middleware-test' import { Route as MarkdownCjkRouteImport } from './routes/markdown-cjk' import { Route as InterruptsTestRouteImport } from './routes/interrupts-test' @@ -29,6 +30,7 @@ import { Route as ApiTranscriptionRouteImport } from './routes/api.transcription import { Route as ApiToolsTestRouteImport } from './routes/api.tools-test' import { Route as ApiToolCallLifecycleWireRouteImport } from './routes/api.tool-call-lifecycle-wire' import { Route as ApiSummarizeRouteImport } from './routes/api.summarize' +import { Route as ApiPersistenceDurabilityRouteImport } from './routes/api.persistence-durability' import { Route as ApiOtelUsageRouteImport } from './routes/api.otel-usage' import { Route as ApiOtelMediaRouteImport } from './routes/api.otel-media' import { Route as ApiOpenrouterWebToolsWireRouteImport } from './routes/api.openrouter-web-tools-wire' @@ -69,6 +71,11 @@ const ToolsTestRoute = ToolsTestRouteImport.update({ path: '/tools-test', getParentRoute: () => rootRouteImport, } as any) +const PersistenceDurabilityRoute = PersistenceDurabilityRouteImport.update({ + id: '/persistence-durability', + path: '/persistence-durability', + getParentRoute: () => rootRouteImport, +} as any) const MiddlewareTestRoute = MiddlewareTestRouteImport.update({ id: '/middleware-test', path: '/middleware-test', @@ -165,6 +172,12 @@ const ApiSummarizeRoute = ApiSummarizeRouteImport.update({ path: '/api/summarize', getParentRoute: () => rootRouteImport, } as any) +const ApiPersistenceDurabilityRoute = + ApiPersistenceDurabilityRouteImport.update({ + id: '/api/persistence-durability', + path: '/api/persistence-durability', + getParentRoute: () => rootRouteImport, + } as any) const ApiOtelUsageRoute = ApiOtelUsageRouteImport.update({ id: '/api/otel-usage', path: '/api/otel-usage', @@ -353,6 +366,7 @@ export interface FileRoutesByFullPath { '/interrupts-test': typeof InterruptsTestRoute '/markdown-cjk': typeof MarkdownCjkRoute '/middleware-test': typeof MiddlewareTestRoute + '/persistence-durability': typeof PersistenceDurabilityRoute '/tools-test': typeof ToolsTestRoute '/$provider/$feature': typeof ProviderFeatureRoute '/api/anthropic-bug-test': typeof ApiAnthropicBugTestRoute @@ -383,6 +397,7 @@ export interface FileRoutesByFullPath { '/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute '/api/otel-media': typeof ApiOtelMediaRoute '/api/otel-usage': typeof ApiOtelUsageRoute + '/api/persistence-durability': typeof ApiPersistenceDurabilityRoute '/api/summarize': typeof ApiSummarizeRoute '/api/tool-call-lifecycle-wire': typeof ApiToolCallLifecycleWireRoute '/api/tools-test': typeof ApiToolsTestRoute @@ -409,6 +424,7 @@ export interface FileRoutesByTo { '/interrupts-test': typeof InterruptsTestRoute '/markdown-cjk': typeof MarkdownCjkRoute '/middleware-test': typeof MiddlewareTestRoute + '/persistence-durability': typeof PersistenceDurabilityRoute '/tools-test': typeof ToolsTestRoute '/$provider/$feature': typeof ProviderFeatureRoute '/api/anthropic-bug-test': typeof ApiAnthropicBugTestRoute @@ -439,6 +455,7 @@ export interface FileRoutesByTo { '/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute '/api/otel-media': typeof ApiOtelMediaRoute '/api/otel-usage': typeof ApiOtelUsageRoute + '/api/persistence-durability': typeof ApiPersistenceDurabilityRoute '/api/summarize': typeof ApiSummarizeRoute '/api/tool-call-lifecycle-wire': typeof ApiToolCallLifecycleWireRoute '/api/tools-test': typeof ApiToolsTestRoute @@ -466,6 +483,7 @@ export interface FileRoutesById { '/interrupts-test': typeof InterruptsTestRoute '/markdown-cjk': typeof MarkdownCjkRoute '/middleware-test': typeof MiddlewareTestRoute + '/persistence-durability': typeof PersistenceDurabilityRoute '/tools-test': typeof ToolsTestRoute '/$provider/$feature': typeof ProviderFeatureRoute '/api/anthropic-bug-test': typeof ApiAnthropicBugTestRoute @@ -496,6 +514,7 @@ export interface FileRoutesById { '/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute '/api/otel-media': typeof ApiOtelMediaRoute '/api/otel-usage': typeof ApiOtelUsageRoute + '/api/persistence-durability': typeof ApiPersistenceDurabilityRoute '/api/summarize': typeof ApiSummarizeRoute '/api/tool-call-lifecycle-wire': typeof ApiToolCallLifecycleWireRoute '/api/tools-test': typeof ApiToolsTestRoute @@ -524,6 +543,7 @@ export interface FileRouteTypes { | '/interrupts-test' | '/markdown-cjk' | '/middleware-test' + | '/persistence-durability' | '/tools-test' | '/$provider/$feature' | '/api/anthropic-bug-test' @@ -554,6 +574,7 @@ export interface FileRouteTypes { | '/api/openrouter-web-tools-wire' | '/api/otel-media' | '/api/otel-usage' + | '/api/persistence-durability' | '/api/summarize' | '/api/tool-call-lifecycle-wire' | '/api/tools-test' @@ -580,6 +601,7 @@ export interface FileRouteTypes { | '/interrupts-test' | '/markdown-cjk' | '/middleware-test' + | '/persistence-durability' | '/tools-test' | '/$provider/$feature' | '/api/anthropic-bug-test' @@ -610,6 +632,7 @@ export interface FileRouteTypes { | '/api/openrouter-web-tools-wire' | '/api/otel-media' | '/api/otel-usage' + | '/api/persistence-durability' | '/api/summarize' | '/api/tool-call-lifecycle-wire' | '/api/tools-test' @@ -636,6 +659,7 @@ export interface FileRouteTypes { | '/interrupts-test' | '/markdown-cjk' | '/middleware-test' + | '/persistence-durability' | '/tools-test' | '/$provider/$feature' | '/api/anthropic-bug-test' @@ -666,6 +690,7 @@ export interface FileRouteTypes { | '/api/openrouter-web-tools-wire' | '/api/otel-media' | '/api/otel-usage' + | '/api/persistence-durability' | '/api/summarize' | '/api/tool-call-lifecycle-wire' | '/api/tools-test' @@ -693,6 +718,7 @@ export interface RootRouteChildren { InterruptsTestRoute: typeof InterruptsTestRoute MarkdownCjkRoute: typeof MarkdownCjkRoute MiddlewareTestRoute: typeof MiddlewareTestRoute + PersistenceDurabilityRoute: typeof PersistenceDurabilityRoute ToolsTestRoute: typeof ToolsTestRoute ProviderFeatureRoute: typeof ProviderFeatureRoute ApiAnthropicBugTestRoute: typeof ApiAnthropicBugTestRoute @@ -723,6 +749,7 @@ export interface RootRouteChildren { ApiOpenrouterWebToolsWireRoute: typeof ApiOpenrouterWebToolsWireRoute ApiOtelMediaRoute: typeof ApiOtelMediaRoute ApiOtelUsageRoute: typeof ApiOtelUsageRoute + ApiPersistenceDurabilityRoute: typeof ApiPersistenceDurabilityRoute ApiSummarizeRoute: typeof ApiSummarizeRoute ApiToolCallLifecycleWireRoute: typeof ApiToolCallLifecycleWireRoute ApiToolsTestRoute: typeof ApiToolsTestRoute @@ -741,6 +768,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ToolsTestRouteImport parentRoute: typeof rootRouteImport } + '/persistence-durability': { + id: '/persistence-durability' + path: '/persistence-durability' + fullPath: '/persistence-durability' + preLoaderRoute: typeof PersistenceDurabilityRouteImport + parentRoute: typeof rootRouteImport + } '/middleware-test': { id: '/middleware-test' path: '/middleware-test' @@ -874,6 +908,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiSummarizeRouteImport parentRoute: typeof rootRouteImport } + '/api/persistence-durability': { + id: '/api/persistence-durability' + path: '/api/persistence-durability' + fullPath: '/api/persistence-durability' + preLoaderRoute: typeof ApiPersistenceDurabilityRouteImport + parentRoute: typeof rootRouteImport + } '/api/otel-usage': { id: '/api/otel-usage' path: '/api/otel-usage' @@ -1186,6 +1227,7 @@ const rootRouteChildren: RootRouteChildren = { InterruptsTestRoute: InterruptsTestRoute, MarkdownCjkRoute: MarkdownCjkRoute, MiddlewareTestRoute: MiddlewareTestRoute, + PersistenceDurabilityRoute: PersistenceDurabilityRoute, ToolsTestRoute: ToolsTestRoute, ProviderFeatureRoute: ProviderFeatureRoute, ApiAnthropicBugTestRoute: ApiAnthropicBugTestRoute, @@ -1216,6 +1258,7 @@ const rootRouteChildren: RootRouteChildren = { ApiOpenrouterWebToolsWireRoute: ApiOpenrouterWebToolsWireRoute, ApiOtelMediaRoute: ApiOtelMediaRoute, ApiOtelUsageRoute: ApiOtelUsageRoute, + ApiPersistenceDurabilityRoute: ApiPersistenceDurabilityRoute, ApiSummarizeRoute: ApiSummarizeRoute, ApiToolCallLifecycleWireRoute: ApiToolCallLifecycleWireRoute, ApiToolsTestRoute: ApiToolsTestRoute, diff --git a/testing/e2e/src/routes/api.persistence-durability.ts b/testing/e2e/src/routes/api.persistence-durability.ts new file mode 100644 index 000000000..a657d356e --- /dev/null +++ b/testing/e2e/src/routes/api.persistence-durability.ts @@ -0,0 +1,166 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + INTERRUPT_BINDING_METADATA_KEY, + INTERRUPT_BINDING_VERSION, + canonicalInterruptJson, + digestInterruptJson, + memoryStream, + resumeServerSentEventsResponse, + toServerSentEventsResponse, +} from '@tanstack/ai' +import type { StreamChunk } from '@tanstack/ai' + +/** + * Provider-free harness route for the browser-refresh persistence story. It + * mirrors the production wiring of `examples/.../api.persistent-chat.ts` — a + * `memoryStream(request)` delivery sink plus a GET resume handler that makes the + * connection resumable — but streams a FIXED AG-UI sequence instead of calling + * an LLM, so the e2e is deterministic with nothing to mock. + * + * Two scenarios (`?scenario=`): + * + * - `text` (default) — a run that streams one assistant text message and + * finishes cleanly (`outcome: success`). The client persists the transcript + * to its `localStoragePersistence` combined record; the resume half is + * cleared on the successful terminal. A reload restores the messages. + * - `interrupt` — a run that ends on a single BOUND generic interrupt + * (carrying a resume binding, exactly like `api.foreign-interrupt`). The + * client folds the pending-interrupt resume snapshot into the SAME combined + * record, so a reload rehydrates the interrupt from `localStorage` alone + * (no server round-trip). + * + * Exempt from the aimock policy: this route streams a fixed AG-UI sequence and + * never reaches an LLM provider's HTTP layer, so there is nothing to mock. + */ + +const REPLY_TEXT = 'PERSIST_OK the lighthouse still turns.' + +const confirmSchema = { + type: 'object', + properties: { confirmed: { type: 'boolean' } }, + required: ['confirmed'], +} + +function textRun(threadId: string, runId: string): AsyncIterable { + return (async function* () { + yield { + type: 'RUN_STARTED', + threadId, + runId, + timestamp: Date.now(), + } as StreamChunk + yield { + type: 'TEXT_MESSAGE_START', + messageId: 'assistant-1', + role: 'assistant', + timestamp: Date.now(), + } as StreamChunk + yield { + type: 'TEXT_MESSAGE_CONTENT', + messageId: 'assistant-1', + delta: REPLY_TEXT, + content: REPLY_TEXT, + timestamp: Date.now(), + } as StreamChunk + yield { + type: 'TEXT_MESSAGE_END', + messageId: 'assistant-1', + timestamp: Date.now(), + } as StreamChunk + yield { + type: 'RUN_FINISHED', + threadId, + runId, + timestamp: Date.now(), + outcome: { type: 'success' }, + } as StreamChunk + })() +} + +function interruptRun( + threadId: string, + runId: string, +): AsyncIterable { + return (async function* () { + yield { + type: 'RUN_STARTED', + threadId, + runId, + timestamp: Date.now(), + } as StreamChunk + yield { + type: 'RUN_FINISHED', + threadId, + runId, + timestamp: Date.now(), + outcome: { + type: 'interrupt', + interrupts: [ + { + id: 'confirm-shipment', + reason: 'confirmation', + message: 'Confirm the shipment?', + responseSchema: confirmSchema, + metadata: { + [INTERRUPT_BINDING_METADATA_KEY]: { + v: INTERRUPT_BINDING_VERSION, + kind: 'generic', + interruptId: 'confirm-shipment', + interruptedRunId: runId, + generation: 0, + responseSchemaHash: digestInterruptJson( + canonicalInterruptJson(confirmSchema), + ), + }, + }, + }, + ], + }, + } as StreamChunk + })() +} + +function stringField(body: unknown, key: string): string | undefined { + if (typeof body !== 'object' || body === null || !(key in body)) { + return undefined + } + const value: unknown = (body as Record)[key] + return typeof value === 'string' ? value : undefined +} + +function scenarioOf(request: Request): 'text' | 'interrupt' { + try { + return new URL(request.url).searchParams.get('scenario') === 'interrupt' + ? 'interrupt' + : 'text' + } catch { + return 'text' + } +} + +export const Route = createFileRoute('/api/persistence-durability')({ + server: { + handlers: { + POST: async ({ request }) => { + const body: unknown = await request.json() + const threadId = stringField(body, 'threadId') ?? 'persistence-thread' + const runId = stringField(body, 'runId') ?? crypto.randomUUID() + const stream = + scenarioOf(request) === 'interrupt' + ? interruptRun(threadId, runId) + : textRun(threadId, runId) + return toServerSentEventsResponse(stream, { + durability: { adapter: memoryStream(request) }, + }) + }, + + // Replay a run from the log so a full reload can re-attach to an in-flight + // run by id (`?offset=-1&runId=…`). Read-only: no producer stream is built. + GET: ({ request }) => { + return resumeServerSentEventsResponse({ + adapter: memoryStream(request), + }) + }, + }, + }, +}) diff --git a/testing/e2e/src/routes/persistence-durability.tsx b/testing/e2e/src/routes/persistence-durability.tsx new file mode 100644 index 000000000..9449051ec --- /dev/null +++ b/testing/e2e/src/routes/persistence-durability.tsx @@ -0,0 +1,131 @@ +import { useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { + fetchServerSentEvents, + localStoragePersistence, + useChat, +} from '@tanstack/ai-react' + +/** + * Browser-refresh persistence harness (client half). + * + * A `localStoragePersistence` adapter stores ONE combined `{ messages, resume }` + * record per thread, so a full `page.reload()` restores the conversation — and + * any pending-interrupt resume snapshot — straight from `localStorage`, with no + * server round-trip. The matching provider-free durable endpoint is + * `/api/persistence-durability`. + * + * `?scenario=interrupt` points the connection at the interrupt variant of the + * endpoint and uses a distinct threadId, so the two scenarios never share a + * storage key. + */ + +// Defaults to the ChatPersistedState shape and a JSON codec, so no type +// argument or serialize/deserialize is needed. +const persistence = localStoragePersistence() + +const textConnection = fetchServerSentEvents('/api/persistence-durability') +const interruptConnection = fetchServerSentEvents( + '/api/persistence-durability?scenario=interrupt', +) + +export const Route = createFileRoute('/persistence-durability')({ + component: PersistenceDurabilityPage, + validateSearch: (search: Record) => ({ + scenario: + search.scenario === 'interrupt' + ? ('interrupt' as const) + : ('text' as const), + }), +}) + +function PersistenceDurabilityPage() { + const { scenario } = Route.useSearch() + const isInterrupt = scenario === 'interrupt' + const chatId = isInterrupt + ? 'persistence-durability-interrupt' + : 'persistence-durability-text' + + const { messages, sendMessage, isLoading, interrupts } = useChat({ + threadId: chatId, + connection: isInterrupt ? interruptConnection : textConnection, + persistence, + }) + + const [input, setInput] = useState('') + + const handleSubmit = () => { + const text = input.trim() + if (!text) return + setInput('') + void sendMessage(text) + } + + return ( +
+