diff --git a/.changeset/client-browser-refresh-durability.md b/.changeset/client-browser-refresh-durability.md new file mode 100644 index 000000000..32b6b665c --- /dev/null +++ b/.changeset/client-browser-refresh-durability.md @@ -0,0 +1,19 @@ +--- +'@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. + +**If you hand-rolled a `persistence` adapter, update its write path.** `setItem` now receives the combined `{ messages, resume? }` record where it used to receive a bare `UIMessage[]`, so an adapter that assumed an array will write the new shape and then fail to parse it back — and because adapter reads are best-effort, the failure is silent: the conversation simply does not restore. Read `{ messages, resume? }` in `getItem` (a bare array is still accepted), or switch to the `localStoragePersistence` / `sessionStoragePersistence` / `indexedDBPersistence` adapters below, which handle it for you. + +The `persistence` option also accepts `true` for a server-authoritative chat: the client caches nothing, and on mount it hydrates the thread from the server by its `threadId` (painting the stored transcript and tailing any run still generating). Use it to keep large transcripts off the client while the server stays authoritative for history; it needs a connection with a `hydrate` handler and a server GET endpoint (`reconstructChat`). Passing an adapter is client-authoritative; omitting `persistence` (or `false`) is ephemeral, in-memory only. + +New web storage adapters are exported for this: `localStoragePersistence`, `sessionStoragePersistence`, and `indexedDBPersistence` (plus `StorageUnavailableError` and the `ChatPersistedState` / `ChatStorageAdapter` / `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/define-lock.md b/.changeset/define-lock.md new file mode 100644 index 000000000..547df8e15 --- /dev/null +++ b/.changeset/define-lock.md @@ -0,0 +1,25 @@ +--- +'@tanstack/ai': minor +--- + +Add `defineLock` to `@tanstack/ai/locks`: an identity typer for a `LockStore` +implementation, matching the `define*Store` helpers in `@tanstack/ai-persistence`. +Pass a `withLock` object and get autocomplete and contract checking inline, with +no `: LockStore` annotation, then hand it to `withLocks`. + +```ts +import { defineLock, withLocks } from '@tanstack/ai/locks' + +const locks = defineLock({ + async withLock(key, fn) { + const { release, signal } = await acquire(key) + try { + return await fn(signal) + } finally { + release() + } + }, +}) + +const middleware = [withLocks(locks)] +``` diff --git a/.changeset/define-store-helpers.md b/.changeset/define-store-helpers.md new file mode 100644 index 000000000..5c41da435 --- /dev/null +++ b/.changeset/define-store-helpers.md @@ -0,0 +1,31 @@ +--- +'@tanstack/ai-persistence': minor +--- + +Add per-store typer helpers: `defineMessageStore`, `defineRunStore`, +`defineInterruptStore`, `defineMetadataStore`. + +Each takes a store implementation and returns it typed against the contract, so +you get autocomplete and checking on the object literal inline — no separate +`: MessageStore` return annotation. They compose into `defineAIPersistence`, +which already infers **exact presence**: a store you define is a defined, +non-optional, autocompleted key on `persistence.stores`, and accessing a store +you did not define is a compile error. + +```ts +import { + defineAIPersistence, + defineMessageStore, + defineRunStore, +} from '@tanstack/ai-persistence' + +const persistence = defineAIPersistence({ + stores: { + messages: defineMessageStore({ loadThread, saveThread }), + runs: defineRunStore({ createOrResume, update, get }), + }, +}) + +persistence.stores.runs // RunStore (defined) +persistence.stores.interrupts // compile error — not provided +``` diff --git a/.changeset/fast-fail-rejoin.md b/.changeset/fast-fail-rejoin.md new file mode 100644 index 000000000..a01f42a8f --- /dev/null +++ b/.changeset/fast-fail-rejoin.md @@ -0,0 +1,23 @@ +--- +'@tanstack/ai': minor +'@tanstack/ai-client': patch +--- + +Make a reload rejoin fast, robust, and repeatable. + +- **`memoryStream` first-chunk deadline now defaults to 100ms** (was 30s). The + common from-start join is a reload rejoining a run whose producer ran in a + prior request: an in-flight run's log already holds chunks (it streams + immediately, the deadline never applies), and an empty log means the run is + gone — so failing fast lets the client re-enable input near-instantly instead + of holding a dead connection open. Raise `firstChunkDeadlineMs` for a backend + whose producer can legitimately start well after a joiner attaches. +- **`ChatClient` reload rejoin hardened:** it bounds the wait for the first + chunk and clears a dead resume pointer (so a stale pointer can't pin the UI in + a loading state and can't be retried on the next load); it drops the hydrated + in-flight partial only when real content arrives (never on `RUN_STARTED` + alone), so a rejoin that connects but delivers nothing can't leave an empty + assistant bubble; and it no longer lets a replayed `RUN_STARTED` (which + carries the provider run id) overwrite the persisted resume pointer with an id + the durability log isn't keyed by — so a SECOND consecutive reload still + re-attaches and continues. diff --git a/.changeset/fresh-client-tail.md b/.changeset/fresh-client-tail.md new file mode 100644 index 000000000..d7e1c032b --- /dev/null +++ b/.changeset/fresh-client-tail.md @@ -0,0 +1,41 @@ +--- +'@tanstack/ai-persistence': minor +'@tanstack/ai-client': minor +--- + +Server-authoritative reconnect is now automatic and keyed on the thread, not the run. + +A chat's durable identity is its **thread**; run ids are ephemeral (a single turn +can span several runs via interrupts or tool continuations), so basing reconnect +on a client-cached run id goes stale the moment a turn rolls to a new run. This +moves the whole reconnect story onto the stable thread id, resolved by the server. + +- **`RunStore.findActiveRun(threadId)`** — new optional, feature-detected store + method returning the most recent `'running'` run for a thread. Implemented by + the in-memory reference backend and covered by the conformance testkit, so any + adapter that provides it is held to the same invariants (most-recent-running + wins, thread-scoped, null when idle). +- **`reconstructChat` now returns `{ messages, activeRun, interrupts }`** (was a + bare message array): the stored transcript as UI messages, a cursor to an + in-flight run if one exists, and any pending human-in-the-loop interrupts (tool + approvals / waits) plus the run they paused. It reads the active run before the + transcript so observing "no active run" guarantees the transcript is final + (closing a finish-window race). +- **`@tanstack/ai-client` hydrates itself on mount.** In server-authoritative + mode (`persistence: true`) the client caches no transcript and no run + pointer: on mount `useChat`/`ChatClient` calls the connection's new + `hydrate(threadId)` (a JSON GET against the same endpoint), paints the returned + transcript, and — if a run is in flight — tails it via the existing `joinRun` + durability replay. A reload and the same thread opened on another device are the + identical, server-resolved path. No loader, no `initialMessages`, no + `initialResumeSnapshot`, no app-side fetching required. +- **Interrupts reconstruct from the server too.** A paused approval (a tool with + `needsApproval`) is restored from `reconstructChat`'s `interrupts` exactly as a + persisted resume snapshot would be, so a reload — or another device — re-prompts + the same approve/reject decision and resumes the run it paused. Previously the + pending interrupt was only recoverable from client storage, so a fresh client + showed the paused tool call with no way to resolve it. + +Apps keep the single GET endpoint they already have (durability replay when a +resume cursor is present, else `reconstructChat`); everything else is handled by +the hook. diff --git a/.changeset/locks-to-core.md b/.changeset/locks-to-core.md new file mode 100644 index 000000000..b1b32b497 --- /dev/null +++ b/.changeset/locks-to-core.md @@ -0,0 +1,13 @@ +--- +'@tanstack/ai': minor +'@tanstack/ai-persistence': minor +'@tanstack/ai-sandbox': minor +--- + +Move multi-instance **locks** to `@tanstack/ai` under a dedicated `@tanstack/ai/locks` subpath, and nest persistence agent skills like `ai-core`. + +- **`LockStore` / `InMemoryLockStore` / `LocksCapability` / `getLocks` / `provideLocks` / `withLocks`** live in `@tanstack/ai/locks` (not the main `@tanstack/ai` barrel, and not `@tanstack/ai-persistence`). +- `@tanstack/ai-sandbox` consumes the core `LocksCapability` token (no local lock re-export). +- The locks agent skill moves with the code: `ai-core/locks` in `@tanstack/ai`, not `ai-persistence/locks`. +- Agent skills under `@tanstack/ai-persistence` nest as `skills/ai-persistence/{stores,server,build-*-adapter}/`. +- Docs: locks guide under advanced middleware. diff --git a/.changeset/memorystream-agent-loop-delivery.md b/.changeset/memorystream-agent-loop-delivery.md new file mode 100644 index 000000000..a7760ca82 --- /dev/null +++ b/.changeset/memorystream-agent-loop-delivery.md @@ -0,0 +1,22 @@ +--- +'@tanstack/ai': patch +--- + +Fix `memoryStream` truncating a tool-calling (agent-loop) run at its first tool +call. + +An agent-loop run emits one `RUN_STARTED`/`RUN_FINISHED` pair per iteration +(`finishReason: "tool_calls"` for a turn that calls a tool, then `"stop"` for the +final answer). `memoryStream` treated the _first_ terminal chunk as the end of +the log — both marking the log complete on append and ending the reader on read — +so a run that called a tool was delivered only up to that first `RUN_FINISHED`: +the tool result and everything after (the model's actual answer) never reached +the client, leaving the tool call stuck "running" and the reply missing, on the +initial stream and on any reconnect/reload. + +Completion is now driven solely by the producer calling `close()` (which it does +on every exit — the documented `StreamDurability.close` contract, honored by +`toServerSentEventsResponse`/`resumeServerSentEventsResponse` and detached +producers). The reader tails across per-iteration terminals and ends when the +producer closes, so a tool-calling run is delivered in full — live, on rejoin, +and on a server-authoritative reload. diff --git a/.changeset/persistence-packages.md b/.changeset/persistence-packages.md new file mode 100644 index 000000000..f6d3b9858 --- /dev/null +++ b/.changeset/persistence-packages.md @@ -0,0 +1,21 @@ +--- +'@tanstack/ai-persistence': 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. + +`@tanstack/ai-persistence` ships the **contract**, not a backend for your database: + +- The four store interfaces — `MessageStore`, `RunStore`, `InterruptStore`, `MetadataStore` — with the invariants the middleware depends on (full-replace `saveThread`, idempotent `createOrResume`, insert-if-absent interrupt `create`, `requestedAt`-ascending listings). +- The `withPersistence` / `withGenerationPersistence` middleware, plus `composePersistence` to assemble stores that live in different systems. +- `memoryPersistence()`, an in-process reference backend for dev and tests. +- `LockStore` / `withLocks` / `InMemoryLockStore` for cross-worker coordination — deliberately **not** a state store, and not composable through `composePersistence`. +- A shared conformance testkit at `@tanstack/ai-persistence/testkit`. `runPersistenceConformance` exercises every method of every store you provide and fails loudly on a store that is missing without being declared in `skip`. + +Implement the stores against whatever database you already run and hand the result to `withPersistence` — the core never inspects your tables, so the schema stays yours. The [Build Your Own Adapter](https://tanstack.com/ai/latest/docs/persistence/build-your-own-adapter) guide walks through a complete `node:sqlite` backend end to end, and the package ships Agent Skills with worked Drizzle, Prisma, and Cloudflare D1 recipes (`npx @tanstack/intent@latest install`). `examples/ts-react-chat` runs on a self-contained `node:sqlite` adapter built this way and verified by the conformance testkit. + +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/rejoin-not-aborted-on-mount.md b/.changeset/rejoin-not-aborted-on-mount.md new file mode 100644 index 000000000..69da6fc16 --- /dev/null +++ b/.changeset/rejoin-not-aborted-on-mount.md @@ -0,0 +1,11 @@ +--- +'@tanstack/ai-react': patch +--- + +Fix `useChat` aborting an in-flight delivery resume on mount. When `live` was +not enabled, the mount effect called `client.unsubscribe()` unconditionally, +which cancelled the shared in-flight stream — including the `joinRun` rejoin the +client had just started for a reloaded run. The result was a mid-stream reload +that caught up to the buffered point and then froze instead of continuing. +`useChat` now only tears down a subscription it actually started, so a reload +rejoins and streams the run through to completion. 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/.changeset/skill-discoverability.md b/.changeset/skill-discoverability.md new file mode 100644 index 000000000..d0d884dba --- /dev/null +++ b/.changeset/skill-discoverability.md @@ -0,0 +1,29 @@ +--- +'@tanstack/ai': patch +'@tanstack/ai-mcp': patch +'@tanstack/ai-sandbox': patch +'@tanstack/ai-memory': patch +--- + +**Make every bundled Agent Skill discoverable by TanStack Intent.** + +Intent finds skills by scanning `node_modules` for packages that carry the +`tanstack-intent` keyword, and can only load what npm actually publishes. Three +packages shipped skills that failed one half of that contract: + +- `@tanstack/ai-mcp` wrote its skill into a `skills/` directory that was missing + from `files`, so it was never published at all. +- `@tanstack/ai-memory` and `@tanstack/ai-sandbox` published their skills but + lacked the keyword, so Intent never looked at them. + +All three now publish `skills` and carry the keyword, matching `@tanstack/ai`, +`@tanstack/ai-code-mode`, and `@tanstack/ai-persistence`. + +The client persistence skill also moves from `@tanstack/ai-persistence` to +`@tanstack/ai` as `ai-core/client-persistence`. It teaches +`localStoragePersistence` / `sessionStoragePersistence` / `indexedDBPersistence` +and the `persistence` option on `useChat` — all of which live in the framework +packages, not in `@tanstack/ai-persistence`. An app doing browser-only +persistence never installs that package, so the guidance was unreachable for +exactly the people who needed it, and `ai-core` routed to a path that did not +exist on disk. Skills now follow the code that owns them. diff --git a/.changeset/usechat-threadid-identity.md b/.changeset/usechat-threadid-identity.md new file mode 100644 index 000000000..90f65cbda --- /dev/null +++ b/.changeset/usechat-threadid-identity.md @@ -0,0 +1,32 @@ +--- +'@tanstack/ai-react': minor +'@tanstack/ai-preact': minor +'@tanstack/ai-solid': minor +'@tanstack/ai-vue': minor +'@tanstack/ai-svelte': minor +'@tanstack/ai-client': minor +--- + +The chat hooks no longer take an `id` option — a hook's identity is its `threadId`. + +`useChat` / `createChat` previously accepted a separate `id` that keyed client +persistence and named the devtools instance, defaulting to a framework +`useId()` when omitted. That meant persistence keyed on an ephemeral render-tree +id even when you passed a stable `threadId`, so a reload found nothing under the +thread's key. + +Now the `threadId` is the single identity: + +- The hooks drop the `id` option. Pass `threadId` to persist a conversation and + restore it on reload; omit it for an ephemeral chat. +- Persistence keys on `threadId` (unchanged in `ChatClient`, which already + resolved `id ?? threadId` — the hooks simply stop overriding it). +- `ChatClient.uniqueId` (the devtools instance id) now falls back to `threadId` + instead of a generated id, so a thread shows up in devtools under its own id. +- Changing `threadId` on a mounted `useChat` (react/preact/solid) now recreates + the client so the new thread takes effect; previously the change was ignored. + +`ChatClient` still accepts `id` directly as a lower-level escape hatch for +keying storage separately from the wire thread; only the framework hooks drop it. + +Migration: replace `useChat({ id })` with `useChat({ threadId })`. diff --git a/docs/advanced/locks.md b/docs/advanced/locks.md new file mode 100644 index 000000000..8ebad8c66 --- /dev/null +++ b/docs/advanced/locks.md @@ -0,0 +1,187 @@ +--- +title: Locks +id: locks +order: 3 +description: "Cross-instance mutual exclusion with LockStore and withLocks — coordination middleware for multi-worker critical sections (e.g. sandbox ensure)." +keywords: + - tanstack ai + - locks + - withLocks + - LockStore + - InMemoryLockStore + - middleware + - multi-instance + - durable object + - AbortSignal + - coordination +--- + +Locks answer a different question from persistence: + +| Concern | Question | Seam | +| --- | --- | --- | +| **State** | What is durable? | Stores + `withPersistence` | +| **Locks** | Who may run this critical section right now? | `LockStore` + `withLocks` | + +They live in **`@tanstack/ai`** as a middleware capability — not in +`@tanstack/ai-persistence`, and never as a key on `AIPersistence.stores`. + +## When you need them + +Use locks when **more than one process or isolate** might enter the same critical +section for the same key: + +- **Sandbox resume-or-create** (`withSandbox` / `ensure`) — two concurrent runs + for the same thread must not both create a provider sandbox. See + [Sandboxes](../sandbox/overview). +- **Your own middleware** — any multi-writer work you want to serialize across + workers (e.g. a custom “one active job per thread” gate). + +You do **not** need locks for: + +- Single-process local dev (optional: `InMemoryLockStore` is fine). +- Ordinary chat state durability — that is stores, not mutexes. +- Automatically locking an entire `chat()` turn — `withLocks` only **provides** + the capability; consumers call `withLock` when they need exclusion. + +## Wire it up + +```ts +import { chat } from '@tanstack/ai' +import { withLocks, InMemoryLockStore } from '@tanstack/ai/locks' +import { grokBuildText } from '@tanstack/ai-grok-build' +import type { ModelMessage } from '@tanstack/ai' + +const messages: Array = [{ role: 'user', content: 'hi' }] + +chat({ + adapter: grokBuildText('grok-build'), + messages, + middleware: [ + // Single process. Multi-instance: pass a distributed LockStore instead. + withLocks(new InMemoryLockStore()), + // later middleware can getLocks(ctx) / withSandbox will use the same token + ], +}) +``` + +Capability identity is by **object reference**. `withLocks` provides the shared +`LocksCapability` from core; any later middleware that reads that token (including +`@tanstack/ai-sandbox`) sees the same store. + +Typical order when composing with sandbox: + +```ts +import { withLocks, InMemoryLockStore } from '@tanstack/ai/locks' +import { withSandbox } from '@tanstack/ai-sandbox' +import type { SandboxDefinition } from '@tanstack/ai-sandbox' + +declare const sandbox: SandboxDefinition + +const middleware = [ + withLocks(new InMemoryLockStore()), + withSandbox(sandbox), // after providers +] +``` + +## The contract + +```ts +import type { LockStore } from '@tanstack/ai/locks' + +declare const locks: LockStore + +// Mutual exclusion for a key; lease-backed impls abort `signal` on loss. +await locks.withLock('thread:abc', async (signal) => { + // critical section — pass `signal` to cancellable work when using leases + void signal +}) +``` + +| Piece | Role | +| --- | --- | +| `LockStore` | Interface: `withLock(key, fn)` | +| `withLocks(store)` | Chat middleware that provides `LocksCapability` | +| `InMemoryLockStore` | Process-local implementation (promise chain per key) | +| `getLocks` / `provideLocks` | Low-level capability accessors for custom middleware | + +`InMemoryLockStore` is correct **within one process only**. It serializes +callers for the same key, does not poison the chain when a critical section +throws, and never aborts its signal (ownership cannot be lost in-process). + +## Implement a lock + +Wrap your own mutual-exclusion primitive with `defineLock`. It types the object +against the contract inline (autocomplete, no `: LockStore` annotation). Acquire +the key, run `fn`, and release when `fn` settles (whether it resolves or throws): + +```ts +import { defineLock } from '@tanstack/ai/locks' +// Your distributed primitive. `acquire` waits until the key is free and returns +// a `release` (plus, for leases, a `signal` that fires when ownership is lost). +import { acquire } from './my-lock-backend' + +export const locks = defineLock({ + async withLock(key, fn) { + const { release, signal } = await acquire(key) + try { + return await fn(signal) + } finally { + release() + } + }, +}) +``` + +Wire it as middleware with `withLocks(locks)`. The requirements a production +store must meet are covered below. + +## Distributed locks and leases + +Multi-instance deployments need a **distributed** implementation (Durable Object, +Redis, etc.). A good store: + +1. Serializes owners per `key`. +2. Uses **leases** (or equivalent) so a crashed owner cannot block forever. +3. Passes an `AbortSignal` into `fn`; when the lease is lost, **abort** so the + callback stops starting externally visible work and passes the signal to + cancellable dependencies. + +Callbacks that ignore `signal` still type-check (`() => Promise` is +assignable), but lease-backed backends cannot protect you if the critical +section keeps mutating after abort. + +There is no shared lock conformance suite in the chat store testkit — write +targeted tests for concurrency, release-on-throw, and lease expiry for your +backend. The Cloudflare Durable Object recipe lives in the +`ai-persistence/build-cloudflare-adapter` agent skill (app-owned file, +not a shipped package). + +## Consume in custom middleware + +```ts +import { defineChatMiddleware } from '@tanstack/ai' +import { LocksCapability, getLocks } from '@tanstack/ai/locks' + +const serializePerThread = defineChatMiddleware({ + name: 'serialize-per-thread', + requires: [LocksCapability], + async onStart(ctx) { + const locks = getLocks(ctx) + await locks.withLock(`thread:${ctx.threadId}`, async (signal) => { + // critical section — honor `signal` under lease-backed locks + void signal + }) + }, +}) +``` + +Or provide without `withLocks` by calling `provideLocks` in your own +`setup` hook if you already own a custom middleware. + +## See also + +- [Middleware](./middleware) — capability bus and lifecycle +- [Sandboxes](../sandbox/overview) — primary product consumer of locks today +- [Persistence Controls](../persistence/controls) — compose state stores from different systems +- [Build Your Own Adapter](../persistence/build-your-own-adapter) — chat store contracts 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 de57b5fb6..72f9c741e 100644 --- a/docs/config.json +++ b/docs/config.json @@ -1,4 +1,4 @@ -{ +{ "$schema": "https://raw.githubusercontent.com/TanStack/tanstack.com/main/tanstack-docs-config.schema.json", "docSearch": { "appId": "FQ0DQ6MA3C", @@ -53,7 +53,8 @@ { "label": "Agent Skills (TanStack Intent)", "to": "getting-started/agent-skills", - "addedAt": "2026-04-17" + "addedAt": "2026-04-17", + "updatedAt": "2026-07-26" } ] }, @@ -109,7 +110,7 @@ "label": "Tool Approval Flow", "to": "tools/tool-approval", "addedAt": "2026-04-15", - "updatedAt": "2026-07-21" + "updatedAt": "2026-07-24" }, { "label": "Lazy Tool Discovery", @@ -175,12 +176,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" } ] }, @@ -225,7 +220,8 @@ { "label": "Overview", "to": "resumable-streams/overview", - "addedAt": "2026-07-17" + "addedAt": "2026-07-17", + "updatedAt": "2026-07-23" }, { "label": "Advanced", @@ -239,6 +235,53 @@ } ] }, + { + "label": "Persistence", + "children": [ + { + "label": "Overview", + "to": "persistence/overview", + "addedAt": "2026-07-22", + "updatedAt": "2026-07-27" + }, + { + "label": "Chat Persistence", + "to": "persistence/chat-persistence", + "addedAt": "2026-07-22", + "updatedAt": "2026-07-26" + }, + { + "label": "Client Persistence", + "to": "persistence/client-persistence", + "addedAt": "2026-07-22", + "updatedAt": "2026-07-27" + }, + { + "label": "Controls", + "to": "persistence/controls", + "addedAt": "2026-07-22", + "updatedAt": "2026-07-25" + }, + { + "label": "Build Your Own Adapter", + "to": "persistence/build-your-own-adapter", + "addedAt": "2026-07-24", + "updatedAt": "2026-07-27" + }, + { + "label": "Migrations", + "to": "persistence/migrations", + "addedAt": "2026-07-22", + "updatedAt": "2026-07-25" + }, + { + "label": "Internals", + "to": "persistence/internals", + "addedAt": "2026-07-22", + "updatedAt": "2026-07-25" + } + ] + }, { "label": "Protocol", "children": [ @@ -382,6 +425,11 @@ "to": "advanced/built-in-middleware", "addedAt": "2026-06-03" }, + { + "label": "Locks", + "to": "advanced/locks", + "addedAt": "2026-07-27" + }, { "label": "OpenTelemetry", "to": "advanced/otel", @@ -568,7 +616,7 @@ "updatedAt": "2026-07-08" }, { - "label": "Sampling \u2192 modelOptions", + "label": "Sampling → modelOptions", "to": "migration/sampling-options-to-model-options", "addedAt": "2026-06-03" } diff --git a/docs/getting-started/agent-skills.md b/docs/getting-started/agent-skills.md index a3441133d..da8c55eeb 100644 --- a/docs/getting-started/agent-skills.md +++ b/docs/getting-started/agent-skills.md @@ -14,32 +14,13 @@ keywords: - SKILL.md - AGENTS.md --- - -You're building with TanStack AI and using an AI coding agent — Claude Code, Cursor, GitHub Copilot, or similar. The agent keeps suggesting Vercel-AI-SDK patterns like `streamText()` or `createOpenAI()`, or it wires streams manually instead of using `toServerSentEventsResponse()`. By the end of this guide, your agent will load TanStack AI's bundled skills automatically whenever you work on AI code — and those skills will stay in sync with whichever `@tanstack/ai` version your project installs. - > **Looking for runtime skills inside Code Mode?** Those are a different feature — see [Code Mode with Skills](../code-mode/code-mode-with-skills). This page is about _agent-authoring_ skills: markdown files that teach your coding assistant how TanStack AI works. - -## What are Agent Skills? - -Agent Skills are markdown documents (`SKILL.md`) that ship inside npm packages and tell AI coding agents how to use a library correctly — which functions to use, which patterns to avoid, and when to reach for which module. The format is an open standard supported by Claude Code, Cursor, GitHub Copilot, Codex, and others. - -TanStack AI publishes skills inside its packages so the guidance travels with `npm update` instead of being pinned in a model's training data or copy-pasted into `CLAUDE.md` manually. - -## Skills Shipped by TanStack AI - -| Package | Skill | What it teaches | -|---------|-------|-----------------| -| `@tanstack/ai` | `ai-core` | Chat experience, tool calling, adapters, middleware, structured outputs, media generation, AG-UI protocol, custom backends | -| `@tanstack/ai-code-mode` | `ai-code-mode` | Setting up Code Mode with a sandbox driver and registering server tools | - -Each skill lives under `node_modules//skills//SKILL.md` once the package is installed. - ## Step 1: Install TanStack AI If you haven't already, install `@tanstack/ai` plus any adapter packages you need. See the [Quick Start](./quick-start) for a full walkthrough. ```bash -pnpm add @tanstack/ai @tanstack/ai-openai +pnpm add @tanstack/ai ``` ## Step 2: Run `intent install` @@ -50,13 +31,39 @@ From the root of your project, run: npx @tanstack/intent@latest install ``` -The CLI walks your agent through the setup. It scans `node_modules` for every package that ships skills (any package with the `tanstack-intent` keyword), asks your agent to propose task-to-skill mappings that match your codebase, and writes them into your agent's config file. -By default the mappings land in `AGENTS.md`. The CLI can also target: +## What are Agent Skills? + +Agent Skills are markdown documents (`SKILL.md`) that ship inside npm packages and tell AI coding agents how to use a library correctly — which functions to use, which patterns to avoid, and when to reach for which module. The format is an open standard supported by Claude Code, Cursor, GitHub Copilot, Codex, and others. + +TanStack AI publishes skills inside its packages so the guidance travels with `npm update` instead of being pinned in a model's training data or copy-pasted into `CLAUDE.md` manually. + +## Skills Shipped by TanStack AI + +| Package | Skill | What it teaches | +|---------|-------|-----------------| +| `@tanstack/ai` | `ai-core` | Chat experience, browser persistence on `useChat`, tool calling, adapters, middleware, locks, structured outputs, media generation, AG-UI protocol, custom backends | +| `@tanstack/ai-persistence` | `ai-persistence` | Server chat state (`withPersistence`), the store contracts, and per-stack recipes that write a `chat-persistence.ts` into your app against your existing Drizzle, Prisma, or Cloudflare D1 setup | +| `@tanstack/ai-memory` | `tanstack-ai-memory` | `memoryMiddleware`, the recall/save adapter contract, and the in-memory / Redis / Hindsight / Mem0 / Honcho adapters | +| `@tanstack/ai-mcp` | `ai-mcp` | Connecting to MCP servers, running their tools inside `chat()`, resources, prompts, and the type-generating CLI | +| `@tanstack/ai-sandbox` | `ai-sandbox` | Running harness adapters inside isolated sandboxes with `defineSandbox` / `withSandbox` | +| `@tanstack/ai-code-mode` | `ai-code-mode` | Setting up Code Mode with a sandbox driver and registering server tools | + +Skills route to each other: `ai-core` points at the companion packages' +skills, and `ai-persistence` is an entry point that routes to its own +sub-skills (`server`, `stores`, and the +`build-{drizzle,prisma,cloudflare,custom}-adapter` recipes) under +`skills/ai-persistence/`, same nesting style as `ai-core`. Multi-instance locks +ship with the code they teach, so `ai-core/locks` lives in `@tanstack/ai` +alongside `withLocks`. + +Each skill ships with the code it teaches. Browser persistence lives in the +framework packages, so `ai-core/client-persistence` is in `@tanstack/ai` rather +than in `@tanstack/ai-persistence` — an app that persists only in the browser +never installs the server package. + +Each skill lives under `node_modules//skills//SKILL.md` once the package is installed. -- `CLAUDE.md` — Claude Code -- `.cursorrules` — Cursor -- any other agent config file you point it at ## Step 3: Review the Generated Mappings @@ -68,6 +75,8 @@ The install command appends (or creates) an `intent-skills` block that looks lik skills: - task: "Building chat, tool calling, adapters, or streaming with TanStack AI" load: "node_modules/@tanstack/ai/skills/ai-core/SKILL.md" + - task: "Persisting chat state or building a persistence adapter" + load: "node_modules/@tanstack/ai-persistence/skills/ai-persistence/SKILL.md" - task: "Setting up Code Mode with TanStack AI" load: "node_modules/@tanstack/ai-code-mode/skills/ai-code-mode/SKILL.md" diff --git a/docs/getting-started/devtools.md b/docs/getting-started/devtools.md index 6569c087b..fcae3b717 100644 --- a/docs/getting-started/devtools.md +++ b/docs/getting-started/devtools.md @@ -40,7 +40,7 @@ import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' export function SupportChat() { const chat = useChat({ - id: 'support-chat', + threadId: 'support-chat', connection: fetchServerSentEvents('/api/chat'), devtools: { name: 'Support Chat', diff --git a/docs/persistence/build-your-own-adapter.md b/docs/persistence/build-your-own-adapter.md new file mode 100644 index 000000000..66988ce88 --- /dev/null +++ b/docs/persistence/build-your-own-adapter.md @@ -0,0 +1,709 @@ +--- +title: Build Your Own Adapter +id: build-your-own-adapter +--- + +# Build Your Own Persistence Adapter + +You want server-side chat persistence, but your data lives in your own database: +Postgres behind Prisma, a SQLite file, Cloudflare D1, Mongo, whatever you already +run. TanStack AI does not ship a backend for your exact stack, and you would +rather not add one more service just for chat history. + +You do not need a packaged backend. Server persistence is a small set of plain +store interfaces from `@tanstack/ai-persistence`. Implement the ones you want +against your database, hand the result to `withPersistence`, and you are done. +The core never inspects your tables, so the schema is yours to shape. + +This guide builds a complete SQLite adapter on Node's built-in `node:sqlite`, end +to end, then shows how to map the same contracts onto a database schema you +already have. The runnable version of everything here lives in the +`examples/ts-react-chat` app (`src/lib/sqlite-persistence.ts`). + +## What an adapter is + +An adapter is an object with a `stores` map: + +```ts +import type { ChatTranscriptPersistence } from '@tanstack/ai-persistence' +// `messages` and `runs` are your store implementations (built below); import +// them from your own modules. +import { messages } from './message-store' +import { runs } from './run-store' + +const persistence: ChatTranscriptPersistence = { + stores: { messages, runs }, +} +``` + +Each store is independent. Provide only the ones you need: `messages` for the +transcript, `runs` for run lifecycle, `interrupts` for durable approvals (needs +`runs`), `metadata` for namespaced key/value state. The middleware turns on +behavior for whatever stores it finds, so a `messages`-only adapter is a valid +adapter. + +Those four are the *only* keys `stores` accepts — anything else throws +`Unknown AIPersistence store key` at construction. Need a mutex across +instances? That is `withLocks`; see [Locks](../advanced/locks). + +Type each store with its `define*Store` helper — `defineMessageStore`, +`defineRunStore`, `defineInterruptStore`, `defineMetadataStore` — as the sections +below do. Each checks the object against the contract inline (autocomplete, no +`: MessageStore` annotation) and composes into `defineAIPersistence`, which +tracks **exact presence**: the stores you pass are defined, autocompleted keys on +`persistence.stores`, and accessing one you did not pass is a compile error. + +Annotate the value with a named shape — `ChatPersistence` for all four, +`ChatTranscriptPersistence` for the floor. Bare `AIPersistence` is the +all-optional bag, and `withPersistence` rejects it because `stores.messages` is +possibly `undefined`. + +Every method signature and invariant is in the +[store interface reference](#store-interface-reference) at the end of this page. +The invariants (idempotent creates, insert-if-absent, ordered listings) are what +the shared conformance suite checks, and getting one wrong is the usual source of +subtle bugs. + +## New database: a SQLite adapter start to finish + +### 1. The schema + +Four tables. JSON payloads are stored as text (SQLite has no JSON column type), +timestamps as integers (epoch milliseconds), everything keyed the way the store +methods look records up. + +```sql +CREATE TABLE IF NOT EXISTS messages ( + thread_id text PRIMARY KEY NOT NULL, + messages_json text NOT NULL +); +CREATE TABLE IF NOT EXISTS 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 +); +CREATE TABLE IF NOT EXISTS 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 +); +CREATE TABLE IF NOT EXISTS metadata ( + scope text NOT NULL, + key text NOT NULL, + value_json text NOT NULL, + PRIMARY KEY (scope, key) +); +``` + +### 2. Messages: full-transcript overwrite + +`saveThread` always receives the complete, authoritative history. It is a +replace, not an append. `loadThread` returns `[]` for a thread that was never +saved, never `null`. + +```ts +import { DatabaseSync } from 'node:sqlite' +import { defineMessageStore } from '@tanstack/ai-persistence' +import type { ModelMessage } from '@tanstack/ai' + +// `defineMessageStore` types the object inline against the contract — you get +// autocomplete and checking with no separate `: MessageStore` annotation. +function createMessageStore(db: DatabaseSync) { + const select = db.prepare( + 'SELECT messages_json FROM messages WHERE thread_id = ?', + ) + const upsert = db.prepare( + `INSERT INTO messages (thread_id, messages_json) VALUES (?, ?) + ON CONFLICT(thread_id) DO UPDATE SET messages_json = excluded.messages_json`, + ) + return defineMessageStore({ + async loadThread(threadId) { + const json = select.get(threadId)?.messages_json + // Unknown thread → [] (never null). `node:sqlite` types columns as a + // SQL-value union, so narrow to string before parsing (no cast). + if (typeof json !== 'string') return [] + const parsed: Array = JSON.parse(json) + return parsed + }, + async saveThread(threadId, messages) { + upsert.run(threadId, JSON.stringify(messages)) + }, + }) +} +``` + +The methods are `async`, so `node:sqlite` (a synchronous driver) needs no +`Promise.resolve` wrapper: `async` promotes the returned value to a promise, and +a method that returns nothing resolves to `void`. On an async driver, `await` the +query instead. + +### 3. Runs: idempotent create, patch, get + +`createOrResume` must be idempotent. If the run id already exists, return the +stored record unchanged, so resuming a run never resets its `startedAt` or +status. `INSERT ... ON CONFLICT DO NOTHING` gives you that in one statement. +`update` on an unknown run id is a no-op. + +```ts +import { DatabaseSync } from 'node:sqlite' +import { defineRunStore } from '@tanstack/ai-persistence' +import type { RunRecord, RunStatus } from '@tanstack/ai-persistence' + +// The `status` column is text; validate it back into the union (no cast). +function toRunStatus(value: unknown): RunStatus { + switch (value) { + case 'running': + case 'completed': + case 'failed': + case 'interrupted': + return value + default: + throw new TypeError(`Unexpected run status: ${String(value)}`) + } +} + +// `node:sqlite` types columns as a SQL-value union, so coerce/narrow each field +// (String / Number / typeof) rather than casting the whole row. +function mapRun(row: Record): RunRecord { + return { + runId: String(row.run_id), + threadId: String(row.thread_id), + status: toRunStatus(row.status), + startedAt: Number(row.started_at), + ...(row.finished_at != null ? { finishedAt: Number(row.finished_at) } : {}), + ...(typeof row.error === 'string' ? { error: row.error } : {}), + ...(typeof row.usage_json === 'string' + ? { usage: JSON.parse(row.usage_json) } + : {}), + } +} + +function createRunStore(db: DatabaseSync) { + const select = db.prepare('SELECT * FROM runs WHERE run_id = ?') + const insert = db.prepare( + `INSERT INTO runs (run_id, thread_id, status, started_at) VALUES (?, ?, ?, ?) + ON CONFLICT(run_id) DO NOTHING`, + ) + const active = db.prepare( + `SELECT * FROM runs WHERE thread_id = ? AND status = 'running' + ORDER BY started_at DESC LIMIT 1`, + ) + return defineRunStore({ + async createOrResume(input) { + const existing = select.get(input.runId) + if (existing) return mapRun(existing) + const status: RunStatus = input.status ?? 'running' + insert.run(input.runId, input.threadId, status, input.startedAt) + return { + runId: input.runId, + threadId: input.threadId, + status, + startedAt: input.startedAt, + } + }, + async update(runId, patch) { + const sets: Array = [] + const params: Array = [] + if (patch.status !== undefined) { + sets.push('status = ?') + params.push(patch.status) + } + if (patch.finishedAt !== undefined) { + sets.push('finished_at = ?') + params.push(patch.finishedAt) + } + if (patch.error !== undefined) { + sets.push('error = ?') + params.push(patch.error) + } + if (patch.usage !== undefined) { + sets.push('usage_json = ?') + params.push(JSON.stringify(patch.usage)) + } + if (sets.length === 0) return + params.push(runId) + db.prepare(`UPDATE runs SET ${sets.join(', ')} WHERE run_id = ?`).run( + ...params, + ) + }, + async get(runId) { + const row = select.get(runId) + return row ? mapRun(row) : null + }, + // The most recent still-running run for a thread. `reconstructChat` calls + // this so a hydrating client (a reload, another device, or switching back to + // a generating thread) learns there is a live run and tails it. Skip it and + // the thread always looks idle on hydrate: the transcript restores, but a + // reply that was mid-stream never resumes. + async findActiveRun(threadId) { + const row = active.get(threadId) + return row ? mapRun(row) : null + }, + }) +} +``` + +`update` builds its `SET` list from only the fields present in the patch, so an +empty patch touches nothing and a partial patch leaves other columns alone. Map +each row back with a small helper that omits absent optional fields and parses +the JSON columns. + +### 4. Interrupts: insert-if-absent, ordered listings + +`create` is insert-if-absent: a duplicate interrupt id must never overwrite an +interrupt that was already resolved. Every `list*` method returns records ordered +by `requested_at` ascending, which the middleware relies on. + +```ts +import { DatabaseSync } from 'node:sqlite' +import { defineInterruptStore } from '@tanstack/ai-persistence' +import type { + InterruptRecord, + InterruptStatus, +} from '@tanstack/ai-persistence' + +function toInterruptStatus(value: unknown): InterruptStatus { + switch (value) { + case 'pending': + case 'resolved': + case 'cancelled': + return value + default: + throw new TypeError(`Unexpected interrupt status: ${String(value)}`) + } +} + +function mapInterrupt(row: Record): InterruptRecord { + return { + interruptId: String(row.interrupt_id), + runId: String(row.run_id), + threadId: String(row.thread_id), + status: toInterruptStatus(row.status), + requestedAt: Number(row.requested_at), + ...(row.resolved_at != null ? { resolvedAt: Number(row.resolved_at) } : {}), + payload: + typeof row.payload_json === 'string' ? JSON.parse(row.payload_json) : {}, + ...(typeof row.response_json === 'string' + ? { response: JSON.parse(row.response_json) } + : {}), + } +} + +function createInterruptStore(db: DatabaseSync) { + const insert = db.prepare( + `INSERT INTO interrupts + (interrupt_id, run_id, thread_id, status, requested_at, payload_json, response_json) + VALUES (?, ?, ?, 'pending', ?, ?, ?) + ON CONFLICT(interrupt_id) DO NOTHING`, + ) + const resolveRow = db.prepare( + `UPDATE interrupts SET status = 'resolved', resolved_at = ?, response_json = ? + WHERE interrupt_id = ?`, + ) + const cancelRow = db.prepare( + `UPDATE interrupts SET status = 'cancelled', resolved_at = ? WHERE interrupt_id = ?`, + ) + const selectOne = db.prepare('SELECT * FROM interrupts WHERE interrupt_id = ?') + // Every listing is ORDER BY requested_at ASC — the middleware relies on it. + const byThread = db.prepare( + 'SELECT * FROM interrupts WHERE thread_id = ? ORDER BY requested_at ASC', + ) + const pendingByThread = db.prepare( + `SELECT * FROM interrupts WHERE thread_id = ? AND status = 'pending' + ORDER BY requested_at ASC`, + ) + const byRun = db.prepare( + 'SELECT * FROM interrupts WHERE run_id = ? ORDER BY requested_at ASC', + ) + const pendingByRun = db.prepare( + `SELECT * FROM interrupts WHERE run_id = ? AND status = 'pending' + ORDER BY requested_at ASC`, + ) + return defineInterruptStore({ + async create(record) { + // Insert-if-absent: a duplicate id must never clobber an already-resolved + // interrupt back to pending. + insert.run( + record.interruptId, + record.runId, + record.threadId, + record.requestedAt, + JSON.stringify(record.payload), + record.response === undefined ? null : JSON.stringify(record.response), + ) + }, + async resolve(interruptId, response) { + resolveRow.run( + Date.now(), + response === undefined ? null : JSON.stringify(response), + interruptId, + ) + }, + async cancel(interruptId) { + cancelRow.run(Date.now(), interruptId) + }, + async get(interruptId) { + const row = selectOne.get(interruptId) + return row ? mapInterrupt(row) : null + }, + async list(threadId) { + return byThread.all(threadId).map(mapInterrupt) + }, + async listPending(threadId) { + return pendingByThread.all(threadId).map(mapInterrupt) + }, + async listByRun(runId) { + return byRun.all(runId).map(mapInterrupt) + }, + async listPendingByRun(runId) { + return pendingByRun.all(runId).map(mapInterrupt) + }, + }) +} +``` + +### 5. Metadata: reject nullish + +`(scope, key)` is the composite identity. A SQL backend cannot store a nullish +value in a `NOT NULL` text column, so reject `null` and `undefined` with a clear +error instead of a cryptic driver failure. Callers clear a value with `delete`. + +```ts +import { DatabaseSync } from 'node:sqlite' +import { defineMetadataStore } from '@tanstack/ai-persistence' + +function createMetadataStore(db: DatabaseSync) { + const select = db.prepare( + 'SELECT value_json FROM metadata WHERE scope = ? AND key = ?', + ) + const upsert = db.prepare( + `INSERT INTO metadata (scope, key, value_json) VALUES (?, ?, ?) + ON CONFLICT(scope, key) DO UPDATE SET value_json = excluded.value_json`, + ) + return defineMetadataStore({ + async get(scope, key) { + const json = select.get(scope, key)?.value_json + return typeof json === 'string' ? JSON.parse(json) : null + }, + async set(scope, key, value) { + if (value == null) { + throw new TypeError( + 'Metadata values must be defined, non-null JSON. Use delete() to clear.', + ) + } + upsert.run(scope, key, JSON.stringify(value)) + }, + async delete(scope, key) { + db.prepare('DELETE FROM metadata WHERE scope = ? AND key = ?').run( + scope, + key, + ) + }, + }) +} +``` + +### 6. Assemble the adapter + +Open the database, create the tables, and return the stores as an +`AIPersistence`. `defineAIPersistence` keeps the exact store keys in the type and +rejects unknown keys at runtime. + +```ts +import { DatabaseSync } from 'node:sqlite' +import { defineAIPersistence } from '@tanstack/ai-persistence' +import type { ChatPersistence } from '@tanstack/ai-persistence' +// The four store factories and the schema string, each from your own module. +import { createInterruptStore } from './interrupt-store' +import { createMessageStore } from './message-store' +import { createMetadataStore } from './metadata-store' +import { createRunStore } from './run-store' +import { SCHEMA_SQL } from './schema' + +export function sqlitePersistence(options: { + url: string + migrate?: boolean +}): ChatPersistence { + const db = new DatabaseSync(options.url) + if (options.migrate) db.exec(SCHEMA_SQL) + return defineAIPersistence({ + stores: { + messages: createMessageStore(db), + runs: createRunStore(db), + interrupts: createInterruptStore(db), + metadata: createMetadataStore(db), + }, + }) +} +``` + +That is a complete backend. If you also need a mutex across workers, add +`withLocks` alongside it; see [Locks](../advanced/locks). + +Wire it into `chat()` exactly like any other persistence: + +```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) +} +``` + +## Existing database: map the contracts onto your schema + +You do not have to create the four tables above. If you already have a database, +map each store method onto the tables and columns you already run. Three things +change from the from-scratch version. + +**Your column names, your types.** The core reads and writes only through your +store methods, so name columns whatever you like and use your database's native +types. Store `messages_json` as a real `jsonb` column on Postgres, use a +`timestamptz` for `started_at` and convert to epoch milliseconds in your row +mapper, split `usage` into real columns if you want to query it. The record shape +the methods return is fixed; how you store it is not. + +**Extra columns are fine.** Add a `user_id` to the messages table to scope +threads per user, add `created_at`/`updated_at` audit columns, add a tenant id. +Keep added columns nullable or defaulted so the store's inserts still succeed. The +TanStack AI stores never read or write columns they do not know about. + +**Adopt part of it.** You rarely need all four stores in the same database. Put +`messages` and `runs` in your primary database and nothing else, then fill the +rest from another source with `composePersistence`: + +```ts +import { composePersistence, memoryPersistence } from '@tanstack/ai-persistence' +import { messages, runs } from './my-postgres-stores' + +// Start from any base and replace the stores you own. +export const persistence = composePersistence(memoryPersistence(), { + overrides: { messages, runs }, +}) +``` + +One caveat: `composePersistence` does not add a transaction across different +systems. If `messages` lives in Postgres and `interrupts` in Redis, a write that +must touch both is two writes; design retries and idempotency for that yourself. +The store invariants (idempotent `createOrResume`, insert-if-absent `create`) are +what make those retries safe, which is exactly why they are invariants. + +## Verify with the conformance suite + +Do not eyeball it. `@tanstack/ai-persistence` ships the same conformance test +suite every packaged backend runs. Point it at your factory and it exercises +every method of every store you provide, including the ordering and idempotency +rules that are easy to get subtly wrong. + +```ts +import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' +import { sqlitePersistence } from './sqlite-persistence' + +runPersistenceConformance('my sqlite adapter', () => + sqlitePersistence({ url: ':memory:', migrate: true }), +) +``` + +The adapter above provides all four stores, so there is nothing to declare. A +partial adapter lists what it deliberately omits: + +```ts +import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' +import { transcriptOnlyPersistence } from './transcript-only' + +runPersistenceConformance( + 'transcript-only adapter', + () => transcriptOnlyPersistence(), + { skip: ['runs', 'interrupts', 'metadata'] }, +) +``` + +`skip` accepts only the four state store keys. A store that is absent and not +listed fails the suite loudly, so you cannot ship a half-wired adapter by +accident. When this is green, your adapter is a drop-in for `withPersistence`. +The `examples/ts-react-chat` app runs exactly this test against its SQLite +backend. + +## Let your coding agent write it + +You do not have to type this page out. `@tanstack/ai-persistence` ships +[Agent Skills](../getting-started/agent-skills) that turn it into a recipe your +assistant follows against **your** stack: it reads your existing ORM config, +schema file, and database handle, appends the four tables to the schema you +already have, and writes a single `src/lib/chat-persistence.ts` exporting the +`ChatPersistence` — no new package, no second database client, and no migration +mechanism competing with the one you run. + +Install the skills with [TanStack Intent](https://tanstack.com/intent/latest/docs/overview), +which scans `node_modules` for packages that ship skills and writes the mappings +into your agent's config (`AGENTS.md`, `CLAUDE.md`, `.cursorrules`, …): + +```bash +pnpm add @tanstack/ai-persistence +npx @tanstack/intent@latest install +``` + +Then ask for what you want — "add chat persistence to this app" — and the +matching skill loads itself into context: + +| Skill | Covers | +| ----------------------------------------- | ------------------------------------------------------------------- | +| `ai-persistence` | Entry point — routes to everything below | +| `ai-persistence/server` | `withPersistence`, run lifecycle, interrupts, `reconstructChat` | +| `ai-persistence/stores` | The store contracts and their invariants | +| `ai-core/locks` | `LockStore` / `withLocks` coordination (ships in `@tanstack/ai/locks`) | +| `ai-persistence/build-drizzle-adapter` | `chat-persistence.ts` for a Drizzle app (SQLite / Postgres / MySQL) | +| `ai-persistence/build-prisma-adapter` | `chat-persistence.ts` for a Prisma app | +| `ai-persistence/build-cloudflare-adapter` | `chat-persistence.ts` for a Worker on D1, plus Durable Object locks | +| `ai-persistence/build-custom-adapter` | `chat-persistence.ts` for anything else — raw `pg`, Kysely, SQLite, Mongo, Supabase | + +Browser-side persistence is not in this package — its skill ships with +`@tanstack/ai` as `ai-core/client-persistence`, alongside the framework code it +teaches. + +They are plain Markdown at +`node_modules/@tanstack/ai-persistence/skills//SKILL.md` if you +prefer to read or follow them yourself. + +## Store interface reference + +These are the public contracts from `@tanstack/ai-persistence`. Implement only +the stores you need. + +### MessageStore + +```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. + +### RunStore + +```ts +import type { TokenUsage } from '@tanstack/ai' + +interface RunRecord { + runId: string + threadId: string + status: 'running' | 'completed' | 'failed' | 'interrupted' + startedAt: number // epoch ms + finishedAt?: number // epoch ms, set once the run reaches a terminal status + error?: string + usage?: TokenUsage // token counts, from @tanstack/ai +} + +interface RunStore { + createOrResume(input: { + runId: string + threadId: string + status?: RunRecord['status'] + startedAt: number + }): Promise + update( + runId: string, + patch: Partial< + Pick + >, + ): Promise + get(runId: string): Promise + // The most recent 'running' run for a thread (greatest `startedAt` wins), or + // null when the thread is idle. Optional on the contract, but implement it: + // `reconstructChat` feature-detects it to report `activeRun`, which is how a + // hydrating client tails a run that is still generating. + findActiveRun?(threadId: 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. Implement `findActiveRun` too unless you never tail in-flight runs on +reload: without it, `reconstructChat` always reports `activeRun: null`, so a +client that reloads (or switches back to) a still-generating thread restores the +transcript but never resumes the live reply. + +### InterruptStore + +```ts +interface InterruptRecord { + interruptId: string + runId: string + threadId: string + status: 'pending' | 'resolved' | 'cancelled' + requestedAt: number // epoch ms + resolvedAt?: number // epoch ms, set once resolved or cancelled + payload: Record + response?: unknown +} + +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. + +### MetadataStore + +```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, and `(scope, key)` is the +composite identity. A stored `null` is indistinguishable from absence at the type +level, so wrap a value you must persist as `null` (e.g. `{ value: null }`), or +reject nullish values outright the way the SQLite store above does. + +## Where to go next + +- [Controls](./controls): compose stores from different systems. +- [Locks](../advanced/locks): `LockStore` / `withLocks` coordination. +- [Migrations](./migrations): who owns the schema and when to apply changes. +- [Internals](./internals): the middleware lifecycle your stores plug into. diff --git a/docs/persistence/chat-persistence.md b/docs/persistence/chat-persistence.md new file mode 100644 index 000000000..d0ae5c9d3 --- /dev/null +++ b/docs/persistence/chat-persistence.md @@ -0,0 +1,135 @@ +--- +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. + +```bash +pnpm add @tanstack/ai-persistence +npx @tanstack/intent@latest install +``` + +The second command wires this package's [Agent Skills](../getting-started/agent-skills) +into your coding assistant. Run it before you start — the recipes read your +existing database setup and write the adapter to match, and they encode the +invariants (full-overwrite `saveThread`, insert-if-absent run and interrupt +creates) that are easy to get wrong and expensive to debug. + +## Persist state on the server + +Add the middleware to `chat()` and point it at a backend. Here `persistence` is a +local `./persistence` module: an adapter you build on the core over the database +you already run. [Build your own adapter](./build-your-own-adapter) walks through +a complete SQLite version end to end. + +```ts group=chat-persistence +import { + chat, + chatParamsFromRequestBody, + 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 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 **state** stores the backend provides, no feature +flags. `messages` is required; the rest are optional: + +- `messages` (required) 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`. + +Need a mutex across workers? Add `withLocks` when other middleware needs +multi-instance coordination; see [Locks](../advanced/locks). + +Creating tables on open is convenient for local development. In production, apply +schema changes 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 **four** moments so a reload never loses a turn: + +| Moment | What is written | Best-effort? | +| --- | --- | --- | +| **Start of a run** (`onStart`) | Pending turn (just-submitted user message + prior history) so a reload mid-generation still shows the question | Yes — failure does not abort the run; finish is authoritative | +| **Interrupt boundary** | New interrupt records, run status `interrupted`, and a thread snapshot of current messages | No — store failures propagate | +| **Finish** (`onFinish`) | Complete transcript (including the terminal assistant reply with its stream `messageId` for in-place reload identity), run status `completed`, and commit of consumed resumes | No — transcript is saved **before** the run is marked completed | +| **Optionally while streaming** | Throttled partial assistant text when `snapshotStreaming: true` | Yes | + +```ts group=chat-persistence +const streamingMiddleware = [ + withPersistence(persistence, { snapshotStreaming: true }), +] +``` + +Streaming snapshots default off (finish is the authoritative save); enable +them to trade extra writes for partial-output durability. Tune the interval +with `snapshotIntervalMs` (default `1000`). + +On **error**, the run is marked `failed`. On **abort**, the run is marked +`interrupted`. Resumes accepted in `onConfig` are **not** consumed until a +success boundary (interrupt or finish), so a failed run leaves pending +interrupts retryable with the same resume batch. + +## 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`. + +Persistence is the **server-authoritative resume path**: the middleware +validates the resume batch against pending interrupts, builds +`ChatResumeToolState` (approvals / client-tool results), and **clears** +`config.resume` so the chat engine skips its ephemeral reconstruction (which +needs client message history the persistence flow deliberately omits). Resumes +are committed (resolved/cancelled in the store) only once the run reaches a +successful interrupt or finish boundary. + +## 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). +- Build the backend on the core, and look up the store contracts: + [Build your own adapter](./build-your-own-adapter). Whatever you already run — + Drizzle, Prisma, Cloudflare D1, raw SQL — install the shipped + [Agent Skills](../getting-started/agent-skills) with + `npx @tanstack/intent@latest install` and have your assistant write the + `chat-persistence.ts` against your existing schema. +- 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..29b1103a9 --- /dev/null +++ b/docs/persistence/client-persistence.md @@ -0,0 +1,227 @@ +--- +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). Pass a + storage adapter and it holds the full transcript, restored on reload with no + network. +- **The server owns the chat** (you use [Chat persistence](./chat-persistence)). + Set `persistence: true` and the client caches nothing: on reload it hydrates + the thread from the server by its `threadId`, painting the conversation and + rejoining any run still streaming. That is the server-authoritative 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** from storage with no network. Sync adapters + (`localStorage` / `sessionStorage`) hydrate during construction; IndexedDB + hydrates asynchronously after the database opens (so the first paint may be + empty for a tick). +- **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 a mode + +`persistence` takes a storage adapter or a boolean: + +- an **adapter** (`persistence: localStoragePersistence()`) is client-authoritative. +- **`true`** is server-authoritative. +- **`false`** (or omitted) is off: messages live in memory only and a reload starts empty. + +### An adapter: 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. + +### `true`: server-authoritative + +Pass `persistence: true`. The client stores nothing, no transcript and no resume +pointer. On mount `useChat` hydrates the thread from the server by its +`threadId`: it paints the stored transcript and, if a run is still generating, +tails it to completion. 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. + +You do not fetch or seed the transcript yourself. A reload and the same thread +opened on another device follow the identical path, because the thread id is the +stable key and the server resolves everything from it. No loader, no +`initialMessages`, no extra props. It needs a connection with a `hydrate` handler +(every built-in connection has one) and the server `GET` endpoint below. + +**Client** — a connection, a stable `threadId`, and `persistence: true`: + +```tsx +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' + +const connection = fetchServerSentEvents('/api/chat') + +function Chat({ threadId }: { threadId: string }) { + const { messages, sendMessage } = useChat({ + threadId, + connection, + persistence: true, + }) + return ( +
+ {messages.map((m) => ( +
{m.role}
+ ))} + +
+ ) +} +``` + +**Server** — one `GET` endpoint next to your chat `POST`. Replay the durability +log when the request carries a resume cursor, otherwise return the stored +conversation with `reconstructChat`: + +```ts +import { memoryStream, resumeServerSentEventsResponse } from '@tanstack/ai' +import { reconstructChat } from '@tanstack/ai-persistence' +import { persistence } from './persistence' + +export function GET(request: Request): Response | Promise { + const durability = memoryStream(request) + // A reconnecting client carries a resume cursor (Last-Event-ID / ?offset and + // X-Run-Id / ?runId). Replay the log so the run finishes in place. + if (durability.resumeFrom() !== null) { + return resumeServerSentEventsResponse({ adapter: durability }) + } + // Otherwise return the stored transcript plus a cursor to any in-flight run. + // Guard access in multi-user apps (see authorize in Chat persistence). + return reconstructChat(persistence, request) +} +``` + +`reconstructChat` returns `{ messages, activeRun }`: the transcript as UI +messages, and `activeRun` when a run is still generating for the thread. The +client calls this endpoint on mount and, when `activeRun` is set, tails the run +through the replay branch above. You never handle a run id, and a second device +resumes the live run the same way the original tab does. 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 | +| `persistence: true` | nothing | 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. + +### Writing your own + +Any object with `getItem` / `setItem` / `removeItem` works. The record is one +`{ messages, resume? }` blob per chat id — the transcript plus the pointer that +lets a reload rejoin an in-flight run — so `setItem` receives that whole record, +not a bare message array: + +```ts +import type { + ChatClientPersistence, + ChatPersistedState, +} from '@tanstack/ai-client' + +function isPersistedState(value: unknown): value is ChatPersistedState { + return ( + typeof value === 'object' && + value !== null && + 'messages' in value && + Array.isArray(value.messages) + ) +} + +const persistence: ChatClientPersistence = { + getItem(id) { + const raw = localStorage.getItem(id) + if (raw === null) return null + const parsed: unknown = JSON.parse(raw) + // A bare array is the legacy messages-only format, still accepted. + if (Array.isArray(parsed)) return { messages: parsed } + return isPersistedState(parsed) ? parsed : null + }, + setItem(id, state) { + localStorage.setItem(id, JSON.stringify(state)) + }, + removeItem(id) { + localStorage.removeItem(id) + }, +} +``` + +Reads are best-effort: a `getItem` that throws or returns `null` is treated as +"nothing stored", so an adapter that parses the wrong shape fails **silently** — +the conversation just does not come back. Round-trip your adapter once against a +real reload before shipping it. + +## 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/controls.md b/docs/persistence/controls.md new file mode 100644 index 000000000..cbb49777b --- /dev/null +++ b/docs/persistence/controls.md @@ -0,0 +1,114 @@ +--- +title: Persistence Controls +id: controls +--- + +# Persistence Controls + +Persistence has no feature flags. What you persist is decided by which **state** +stores the backend provides, and you compose backends per store. Supply only the +stores your workflow needs. + +Need a mutex across instances? See [Locks](#locks-coordination) below. + +## Named shapes (prefer these) + +| Type | Required stores | Use | +| --- | --- | --- | +| `ChatTranscriptStores` / `ChatTranscriptPersistence` | `messages` (optional runs/interrupts/metadata) | Floor for `withPersistence` / `reconstructChat` | +| `ChatPersistenceStores` / `ChatPersistence` | `messages` + `runs` + `interrupts` + `metadata` | Packaged backends (`memoryPersistence`, Drizzle, Prisma, D1) | +| `ChatWithInterruptsStores` / `ChatWithInterruptsPersistence` | `messages` + `runs` + `interrupts` | HITL without requiring metadata | + +There is no public sparse `AIPersistenceStores` export — use a named shape or +`AIPersistence<{ messages: MessageStore, … }>` for custom maps. +`defineAIPersistence` / `composePersistence` still accept sparse maps by +inference. + +## What each state store gives you + +| Requirement | Store | +| --- | --- | +| Authoritative server transcript | `messages` (**required** by `withPersistence` / `reconstructChat`) | +| Run status and usage | `runs` (required on `ChatPersistence`; required when `interrupts` is set) | +| Durable approvals or human input | `interrupts` (requires `runs`) | +| App or integration checkpoints | `metadata` (always optional) | + +`withPersistence(persistence)` inspects the stores that are present. Store +presence is the capability selection mechanism for optional chat features. + +## Entrypoint requirements + +| Entrypoint | Shape | Notes | +| --- | --- | --- | +| `withPersistence` | `ChatTranscriptStores` floor | `interrupts` ⇒ `runs` | +| `reconstructChat` | `ChatTranscriptStores` | `runs` / `interrupts` enrich the response when present | +| Packaged `*Persistence()` | `ChatPersistence` | messages + runs (+ interrupts + metadata) | +| `defineAIPersistence` / `composePersistence` | sparse by inference | Prefer a named shape for the result | + +## 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 metadata; the resulting type has no `metadata` key. +const withoutMetadata = composePersistence(memoryPersistence(), { + overrides: { metadata: false }, +}) +``` + +Unknown store names fail type checking, and are also rejected at runtime when +values arrive from untyped JavaScript. + +## Valid store combinations + +- `withPersistence` requires `messages`. +- `interrupts` requires `runs`: an interrupt record is scoped to a run. +- `withGenerationPersistence` requires `runs`. + +To define a partial backend directly rather than by composing, use +`defineAIPersistence({ stores: { ... } })` and pass only the stores you have. +See the +[store interface reference](./build-your-own-adapter#store-interface-reference) +for the store contracts. + +## Locks (coordination) + +Locks coordinate work across instances (a distributed mutex). They live in +`@tanstack/ai/locks` and apply as their own middleware with `withLocks`, +alongside `withPersistence`. Full guide: [Locks](../advanced/locks). + +```ts +import { withLocks, InMemoryLockStore } from '@tanstack/ai/locks' +import { withPersistence, memoryPersistence } from '@tanstack/ai-persistence' + +const middleware = [ + withPersistence(memoryPersistence()), + withLocks(new InMemoryLockStore()), // multi-instance: distributed LockStore +] +``` diff --git a/docs/persistence/internals.md b/docs/persistence/internals.md new file mode 100644 index 000000000..4faf73cc4 --- /dev/null +++ b/docs/persistence/internals.md @@ -0,0 +1,115 @@ +--- +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. + +**Do not treat this as the long-term generation model.** Today it reuses chat +`RunStore` and dual-keys `(runId, threadId)` both to `requestId`. That is a +stopgap: **generation jobs must not fake `threadId = requestId`.** `threadId` +is the shared conversation key (`Scope.threadId`); a generation job's primary +id is `requestId` / `jobId`. The follow-up should introduce a dedicated +generation job store (and later artifact store), not chat `RunStore` / +`MessageStore`. + +## 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, + interrupts: false, + }, +}) +``` + +- `messages` is replaced. +- `metadata` is inherited because the override is `undefined`. +- `interrupts` 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 entrypoint validation: + +- chat requires `messages`; rejects `interrupts` without `runs`. +- generation requires `runs`. +- `reconstructChat` requires `messages`. + +The runtime checks are required because JavaScript, configuration loading, and +explicitly widened types can bypass static guarantees. + +## Backend ownership + +An adapter owns its own resources: connection lifecycle, when migrations run, and +how each store record maps to rows. The middleware only calls the store methods; +it never opens a connection or inspects a table. A backend may provide any subset +of the stores (for example, no `metadata`), and the return type reflects exactly the +stores it exposes. [Build your own adapter](./build-your-own-adapter) shows this +end to end for SQLite. + +`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..e8045e630 --- /dev/null +++ b/docs/persistence/migrations.md @@ -0,0 +1,58 @@ +--- +title: Persistence Migrations +id: migrations +--- + +# Persistence Migrations + +Your adapter owns its schema. TanStack AI never inspects your tables, so you +decide the table layout and how schema changes are applied. Apply those changes +before deploying code that reads or writes the corresponding stores. + +## Create tables on open, for local development + +A hand-rolled adapter can create its tables the first time it opens the database. +The SQLite example in [Build your own adapter](./build-your-own-adapter) does this +behind a `migrate` flag with `CREATE TABLE IF NOT EXISTS`, so it is idempotent: + +```ts ignore +import { sqlitePersistence } from './sqlite-persistence' + +const persistence = sqlitePersistence({ + url: 'file:.data/state.sqlite', + migrate: true, +}) +``` + +This is convenient for local development and tests. Avoid request-time migrations +in production. + +## Apply migrations in production + +In production, run schema changes through your normal deployment workflow, not on +first request. Keep the DDL for the four tables (`messages`, `runs`, +`interrupts`, `metadata`) in a versioned migration and apply it with the same +tool you use for the rest of your database, before the new code ships. + +If you build the adapter with an ORM or query builder, let that tool own the +migration journal. A Drizzle schema drives `drizzle-kit`; a Prisma models +fragment drives `prisma migrate`; a raw SQL adapter checks in plain `.sql` files. +The adapter-building skills in `@tanstack/ai-persistence` cover each of these +workflows. + +## An existing schema owns its own migrations + +If you map the store contracts onto tables you already have (see +[Build your own adapter](./build-your-own-adapter#existing-database-map-the-contracts-onto-your-schema)), +those tables are part of your application schema, and your existing migration tool +already owns them. Add the columns the stores need in a normal migration. Keep any +extra app-owned columns nullable or defaulted so the stores' inserts still +succeed. + +## Upgrade discipline + +1. Keep the DDL for the store tables in a reviewable migration, not inline in + request handlers. +2. Back up production state where required. +3. Apply migrations before deploying code that depends on them. +4. 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..73d037a20 --- /dev/null +++ b/docs/persistence/overview.md @@ -0,0 +1,313 @@ +--- +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. + +## Install + +The server half lives in one package. The client half needs no extra install — +it ships with the framework package you already use (`@tanstack/ai-react`, +`-vue`, `-solid`, `-svelte`, `-angular`, or `@tanstack/ai-client`). + +```bash +pnpm add @tanstack/ai-persistence +``` + +Then wire this package's [Agent Skills](../getting-started/agent-skills) into +your coding assistant, before you write any of it: + +```bash +npx @tanstack/intent@latest install +``` + +Run that after the package is installed, not before — Intent scans +`node_modules`, so anything added later needs another run. + +## 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)) | with a storage adapter, the transcript + a resume pointer in `localStorage` / `sessionStorage` / `IndexedDB`; with `persistence: true`, nothing (hydrates from the server on mount) | a page reload in that browser | instant restore on reload, SPA / offline apps | +| **Server** ([Chat persistence](./chat-persistence)) | messages, run status, interrupts, in your own store | a server restart, and reaches every device | multi-device, audit, durable approvals | + +### Identity: `Scope` and `threadId` + +Server persistence keys conversation history on **`threadId`** — the same +conversation key as `ChatMiddlewareContext.threadId` and the required field of +the shared `Scope` type from `@tanstack/ai`. Store APIs take a bare `threadId` +string for adapter simplicity; multi-user isolation is still required: + +- Derive `Scope.userId` / `Scope.tenantId` **server-side** from session state. +- Authorize before `loadThread` / `saveThread` / `reconstructChat` (use + `reconstructChat({ authorize })`). +- Never treat a client-supplied thread id alone as ownership — thread ids are + guessable. + +`Scope` is re-exported from `@tanstack/ai-persistence` so apps can import the +identity type next to the store contracts. + +A minimal server setup adds one middleware to `chat()`. Here `persistence` comes +from a local `./persistence` module: a small adapter over a durable store that +you build on the core in a few lines. See +[Build your own adapter](./build-your-own-adapter) for a complete SQLite +implementation you can copy. + +```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) +} +``` + +The client half is one option on `useChat`, `persistence`, and it takes two forms: + +- **`persistence: true`** — server-authoritative. The client caches nothing and + hydrates the thread from the server by its `threadId` on mount. Pair this with + the server `withPersistence` above; it is the setup [we recommend](#what-we-recommend). +- **`persistence: `** — client-authoritative. A storage adapter + (`localStoragePersistence()` / `sessionStoragePersistence()` / + `indexedDBPersistence()`) keeps the transcript in the browser, no server needed. + +```tsx +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' + +function Chat() { + const { messages, sendMessage } = useChat({ + threadId: 'support-chat', + connection: fetchServerSentEvents('/api/chat'), + // Server owns history (pairs with withPersistence above): + persistence: true, + // Or keep the transcript in the browser instead: + // 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 + +With a client store adapter, 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 `persistence: true` | +| 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 nothing** with `persistence: true`. The browser holds no transcript and no resume pointer; on mount it hydrates the thread from the server by its `threadId`. +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 { 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, { + 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 `{ messages, activeRun }` — the transcript plus + // a cursor to any run still generating. + // + // Security: without `authorize`, any caller who knows a thread id receives the + // full transcript. In multi-user apps, check session ownership here (or only + // ever pass a server-validated thread id). + return reconstructChat(persistence, request, { + authorize: async (threadId, req) => { + // Replace with your session + ownership check, e.g.: + // const user = await auth(req) + // return user != null && (await db.threadOwnedBy(user.id, threadId)) + void threadId + void req + return true + }, + }) +} +``` + +The client caches nothing. `useChat` calls that `GET` for you on mount: + +```tsx +import { + fetchServerSentEvents, + useChat, +} from '@tanstack/ai-react' + +function Chat() { + const { messages, sendMessage } = useChat({ + threadId: 'support-chat', + connection: fetchServerSentEvents('/api/chat'), + persistence: true, + }) + // Nothing else to wire. On mount useChat fetches GET /api/chat?threadId=..., + // paints the returned transcript, and tails any run still generating. +} +``` + +A mid-stream reload does both jobs off the same `GET`, and `useChat` drives both +for you: it fetches the transcript (`?threadId`, the reconstruct branch), then, +when `reconstructChat` reports an `activeRun`, tails that run +(`?offset=-1&runId`, the resume branch). The `if` in the handler routes each +request; one is never asked to do both. The replayed run's messages merge into +the transcript by message id, so nothing is duplicated or lost. Because the +reconnect is resolved from the stable `threadId` on the server, a reload and the +same thread opened on another device resume the same way. + +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 mount `GET` re-paints the transcript and, when a run is still generating, reports its `activeRun` so the client rejoins it and restores pending interrupts. A reload picks up exactly where it left off. +- **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. This combination avoids both: one server-resolved `GET` on mount restores history and rejoins any live run, so a reload and a second device follow the identical path. + +## The store contract + +Server **state** persistence is a set of stores. Middleware activates behavior +from whichever stores are present (with entrypoint requirements — see +[Controls](./controls)). There is no separate enable list. + +| Store | Purpose | +| --- | --- | +| `messages` | Authoritative model-message history per thread. Required by chat persistence. | +| `runs` | Run status, timing, errors, and usage. Required on full `ChatPersistence`. | +| `interrupts` | Pending, resolved, or cancelled human/tool waits (needs `runs`). | +| `metadata` | App and integration key/value state. | + +Named shapes: `ChatTranscriptStores` (messages floor), `ChatPersistenceStores` +(all four), `ChatWithInterruptsStores`. See [Controls](./controls). + +Need a mutex across instances (cross-worker coordination)? Use `withLocks` and a +`LockStore` from `@tanstack/ai/locks`; see [Locks](../advanced/locks). + +`@tanstack/ai-persistence` ships the contracts, the middleware, an in-memory +reference backend, and a conformance testkit — not a backend for your database. +You implement the stores against whatever you already run; +[Build your own adapter](./build-your-own-adapter) walks through a complete one. + +If you ran `intent install` [above](#install), you can skip the +typing: ask your assistant for "add chat persistence to this app" and the recipe +matching your database loads itself. The full skill list is in +[Build your own adapter](./build-your-own-adapter#let-your-coding-agent-write-it). + +## Where to go next + +- [Chat persistence](./chat-persistence): the server middleware, the authoritative-history contract, and durable interrupts. +- [Client persistence](./client-persistence): client- vs server-authoritative modes (`persistence: true`), reload restore, storage backends, and mid-stream rejoin. +- [Controls](./controls): compose backends per store and choose which stores to run. +- [Build your own adapter](./build-your-own-adapter): a complete SQLite example on the core, plus the store interface reference. +- [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/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/docs/tools/tool-approval.md b/docs/tools/tool-approval.md index 0970495f0..5ca8ab9f4 100644 --- a/docs/tools/tool-approval.md +++ b/docs/tools/tool-approval.md @@ -158,223 +158,63 @@ export async function POST(request: Request) { } ``` -## Deprecated approval response compatibility +## Approval UI -`addToolApprovalResponse` remains temporarily available for old approval UIs, -but new code should use the bound interrupt API above. A compatibility -`approved: false` is a denial, not a cancellation. +Render pending approvals from the hook's `interrupts` array. Each +`tool-approval` interrupt carries the tool name, the original arguments, and a +`resolveInterrupt` you call with the user's decision. The array is already +tool-agnostic, so one block handles every tool marked `needsApproval: true` — +no per-tool `part.name` branch and no reading `part.approval` off a mixed union: -```tsx +```tsx ignore import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' +import { sendEmail } from './tools' function ChatComponent() { - const { messages, sendMessage, addToolApprovalResponse } = useChat({ - connection: fetchServerSentEvents('/api/chat'), - }) - - return ( -
- {messages.map((message) => ( -
- {message.parts.map((part) => { - // Check for approval requests - if ( - part.type === 'tool-call' && - part.state === 'approval-requested' && - part.approval - ) { - return ( -
-

Approve: {part.name}

-
{JSON.stringify(part.input, null, 2)}
- - -
- ) - } - // ... render other parts - return null - })} -
- ))} -
- ) -} -``` - -> **Type safety:** When you pass typed `tools` to `useChat`, the `approval` -> field exists **only** on tool-call parts for tools declared with -> `needsApproval: true` — tools without approval have no `approval` field at -> all, so reading it is a compile error that catches a real footgun (checking -> for approval on a tool that can never request it). See -> [Generic approval handlers](#generic-approval-handlers) for how to write a -> tool-agnostic handler under this constraint. - -## Generic Approval Handlers - -A handler that renders an approval prompt for **any** tool (not one specific -tool) is still fully supported — you just can't read `part.approval` off a -typed mixed tool union without first establishing that the field exists. Pick -whichever of these fits: - -**1. Narrow with `'approval' in part`.** This narrows the tool-call union to -exactly the members that can carry approval, so one loop handles every approval -tool with full type safety: - -```tsx -import { toolDefinition } from '@tanstack/ai' -import { z } from 'zod' -import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' - -const deleteData = toolDefinition({ - name: 'delete_data', - description: 'Delete data (requires approval)', - inputSchema: z.object({ key: z.string() }), - needsApproval: true, -}).client(async ({ key }) => ({ deleted: key })) - -const listData = toolDefinition({ - name: 'list_data', - description: 'List available keys', - inputSchema: z.object({}), -}).client(async () => ({ keys: new Array() })) - -function ApprovalHandler() { - const { messages, addToolApprovalResponse } = useChat({ + const { messages, sendMessage, interrupts, resuming } = useChat({ connection: fetchServerSentEvents('/api/chat'), - tools: [deleteData, listData], + tools: [sendEmail], }) return (
- {messages.flatMap((message) => - message.parts.map((part, i) => { - // `'approval' in part` narrows the union to `needsApproval` tools, - // so this single handler covers every approval tool — no per-tool - // `part.name` branch needed. - if ( - part.type === 'tool-call' && - part.state === 'approval-requested' && - 'approval' in part && - part.approval - ) { - return ( - - ) - } - return null - }), + {/* ...render messages... */} + {interrupts.map((interrupt) => + interrupt.kind === 'tool-approval' ? ( +
+

🔒 Approve {interrupt.toolName}?

+
{JSON.stringify(interrupt.originalArgs, null, 2)}
+ + +
+ ) : null, )}
) } ``` -**2. Type a shared component against the base `ToolCallPart`.** The base type -(from `@tanstack/ai-client`, untyped tools) always carries `approval?`, so a -reusable component works across every tool regardless of the caller's tool -union — this is the [Approval UI Example](#approval-ui-example) below. +`canResolve` stays `false` until the interrupt is bound and ready; `resuming` is +`true` while a resolution is in flight, so gate the buttons on both. -**3. Use an untyped `useChat()`.** With no `tools` generic, every tool-call -part keeps `approval?` exactly as before — no narrowing needed. +## Migrating from `addToolApprovalResponse` -## Approval UI Example - -Here's a more complete approval UI component: - -```tsx -import type { ToolCallPart } from '@tanstack/ai-client' - -function ApprovalPrompt({ - part, - onApprove, - onDeny, -}: { - part: ToolCallPart - onApprove: () => void - onDeny: () => void -}) { - // `part.input` is the parsed, fully-typed argument object — always populated - // by approval time (the arguments are complete). The raw `part.arguments` - // string is still available if you need it. - const args = part.input - - return ( -
-
- 🔒 Approval Required: {part.name} -
-
-
-          {JSON.stringify(args, null, 2)}
-        
-
-
- - -
-
- ) -} -``` - -Wire it up from your message renderer. Note the `id` you pass is the **approval id** (`part.approval.id`), not the tool call id: - -```tsx ignore -{ - part.type === 'tool-call' && - part.state === 'approval-requested' && - part.approval && ( - - addToolApprovalResponse({ id: part.approval!.id, approved: true }) - } - onDeny={() => - addToolApprovalResponse({ id: part.approval!.id, approved: false }) - } - /> - ) -} -``` +Older UIs read `part.approval` off tool-call parts and called +`addToolApprovalResponse({ id, approved })`. That API is deprecated. Render from +the `interrupts` array and call `resolveInterrupt` instead (see [Approval +UI](#approval-ui) above) — it is tool-agnostic by default, so the per-tool +narrowing the part-based pattern needed goes away. For the full mapping, see +[Migrate to AG-UI interrupts](../interrupts/migration). ## Client Tools with Approval @@ -405,11 +245,11 @@ const deleteLocalData = deleteLocalDataDef.client((input) => { return { deleted: true } }) -const { messages, addToolApprovalResponse } = useChat({ +const { messages, interrupts } = useChat({ connection: fetchServerSentEvents('/api/chat'), // Pass client tools as a plain array — literal tool-name inference works - // without a wrapper, so `part.name === "delete_local_data"` still narrows - // `part.input` / `part.output` to this tool's types. + // without a wrapper. The approval surfaces as a `tool-approval` interrupt you + // resolve from `interrupts` (see Approval UI); the tool runs on approval. tools: [deleteLocalData], // Automatic execution after approval }) ``` diff --git a/examples/ts-code-mode-web/src/routes/_home/index.tsx b/examples/ts-code-mode-web/src/routes/_home/index.tsx index 97c98fd90..f8d838c5e 100644 --- a/examples/ts-code-mode-web/src/routes/_home/index.tsx +++ b/examples/ts-code-mode-web/src/routes/_home/index.tsx @@ -700,7 +700,7 @@ function CodeModePanel({ ) const { messages, sendMessage, isLoading, stop } = useChat({ - id: 'product-codemode', + threadId: 'product-codemode', connection: fetchServerSentEvents('/api/product-codemode'), body, onCustomEvent: handleCustomEvent, @@ -998,7 +998,7 @@ function RegularToolsPanel({ }, []) const { messages, sendMessage, isLoading, stop } = useChat({ - id: 'product-regular', + threadId: 'product-regular', connection: fetchServerSentEvents('/api/product-regular'), body, onCustomEvent: handleCustomEvent, 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..ff61b6656 100644 --- a/examples/ts-react-chat/README.md +++ b/examples/ts-react-chat/README.md @@ -369,6 +369,20 @@ 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`, so it +survives a server restart too — the DB lives at `.data/persistent-chat.db` +(gitignored). The SQLite backend is `src/lib/sqlite-persistence.ts`, a +self-contained `node:sqlite` store built on the `@tanstack/ai-persistence` +core store contracts — a worked example of rolling your own adapter. + +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..d39dd9ded 100644 --- a/examples/ts-react-chat/package.json +++ b/examples/ts-react-chat/package.json @@ -7,7 +7,8 @@ "dev:vite": "vite dev --port 3000", "build": "vite build", "serve": "vite preview", - "test": "exit 0", + "test": "vitest run", + "test:lib": "vitest run", "test:types": "tsc --noEmit" }, "dependencies": { @@ -36,6 +37,7 @@ "@tanstack/ai-openai": "workspace:*", "@tanstack/ai-opencode": "workspace:*", "@tanstack/ai-openrouter": "workspace:*", + "@tanstack/ai-persistence": "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..1566b9a15 --- /dev/null +++ b/examples/ts-react-chat/src/lib/persistent-chat-store.ts @@ -0,0 +1,23 @@ +import { sqlitePersistence } from './sqlite-persistence' + +/** 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` creates the + * TanStack AI tables on first open. The `sqlitePersistence` implementation lives + * in `./sqlite-persistence` — a self-contained `node:sqlite` backend built on the + * `@tanstack/ai-persistence` core, demonstrating how to roll your own. `.data/` + * is gitignored. + */ +export function persistentChatPersistence() { + return (instance ??= sqlitePersistence({ + url: './.data/persistent-chat.db', + })) +} diff --git a/examples/ts-react-chat/src/lib/persistent-chat-tools.ts b/examples/ts-react-chat/src/lib/persistent-chat-tools.ts new file mode 100644 index 000000000..184d71c7d --- /dev/null +++ b/examples/ts-react-chat/src/lib/persistent-chat-tools.ts @@ -0,0 +1,25 @@ +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' + +/** + * Isomorphic tool DEFINITION shared by the server and the browser. + * + * The server attaches the implementation with `.server(...)`; the client passes + * this same definition to `useChat({ tools })`. Sharing one definition means the + * approval interrupt's schema hashes match on both sides, so the browser can + * bind the pending approval and resolve it. `needsApproval` pauses the run for a + * human yes/no before the tool runs — persisted by `withPersistence`, so the + * pending decision survives a reload. + */ +export const sendEmailTool = toolDefinition({ + name: 'sendEmail', + description: + 'Send an email on the user’s behalf. Pauses for the user to approve.', + needsApproval: true, + inputSchema: z.object({ + to: z.string().describe('Recipient email address'), + subject: z.string(), + body: z.string(), + }), + outputSchema: z.object({ messageId: z.string(), to: z.string() }), +}) diff --git a/examples/ts-react-chat/src/lib/server-fns.ts b/examples/ts-react-chat/src/lib/server-fns.ts index 1c8109be4..9b8e17223 100644 --- a/examples/ts-react-chat/src/lib/server-fns.ts +++ b/examples/ts-react-chat/src/lib/server-fns.ts @@ -1,4 +1,4 @@ -import { createServerFn } from '@tanstack/react-start' +import { createServerFn } from '@tanstack/react-start' import { z } from 'zod' import { chat, @@ -396,7 +396,7 @@ export const generateVideoStreamFn = createServerFn({ method: 'POST' }) }) // ============================================================================= -// Chat server function — pairs with useChat({ fetcher }) +// Chat server function — pairs with useChat({ fetcher }) // ============================================================================= export const chatFn = createServerFn({ method: 'POST' }) diff --git a/examples/ts-react-chat/src/lib/sqlite-persistence.test.ts b/examples/ts-react-chat/src/lib/sqlite-persistence.test.ts new file mode 100644 index 000000000..3b37f2065 --- /dev/null +++ b/examples/ts-react-chat/src/lib/sqlite-persistence.test.ts @@ -0,0 +1,15 @@ +/** + * Proves the in-example `node:sqlite` backend satisfies the full + * `AIPersistence` contract by running the shared conformance testkit from + * `@tanstack/ai-persistence`. This is exactly how you would verify your own + * hand-rolled adapter: point the testkit at your factory and keep it green. + */ +import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' +import { sqlitePersistence } from './sqlite-persistence' + +// All four state stores are provided, so nothing is skipped. (Locks are not a +// state store and the suite does not cover them — this backend has no +// distributed lock primitive, which is a separate `withLocks` concern.) +runPersistenceConformance('ts-react-chat example (node:sqlite)', () => + sqlitePersistence({ url: ':memory:', migrate: true }), +) diff --git a/examples/ts-react-chat/src/lib/sqlite-persistence.ts b/examples/ts-react-chat/src/lib/sqlite-persistence.ts new file mode 100644 index 000000000..74f1a1198 --- /dev/null +++ b/examples/ts-react-chat/src/lib/sqlite-persistence.ts @@ -0,0 +1,448 @@ +/** + * A self-contained SQLite persistence backend for TanStack AI, built directly + * on the `@tanstack/ai-persistence` **core** store contracts and Node's built-in + * `node:sqlite` driver. No ORM, no extra dependencies — this is the whole thing. + * + * It exists as a worked demonstration of "rolling your own" concrete persistence + * on the core: the four store interfaces (`MessageStore`, `RunStore`, + * `InterruptStore`, `MetadataStore`) are implemented here against raw SQL, and + * the result is a standard `AIPersistence` you hand to `withPersistence(...)`. + * + * The store semantics mirror the reference in-memory backend shipped in + * `@tanstack/ai-persistence` (`memory.ts`) exactly — the shared conformance + * testkit (`sqlite-persistence.test.ts`) is the proof. If you copy this file + * into your own app, keep those invariants intact and the testkit green. + * + * Requires Node 22.5+ (for `node:sqlite`). The TanStack Start server this + * example runs on is a Node runtime, so `DatabaseSync` is available server-side. + */ +import { mkdirSync } from 'node:fs' +import { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { DatabaseSync } from 'node:sqlite' +import { + defineAIPersistence, + defineInterruptStore, + defineMessageStore, + defineMetadataStore, + defineRunStore, +} from '@tanstack/ai-persistence' +import type { ModelMessage, TokenUsage } from '@tanstack/ai' +import type { + ChatPersistence, + InterruptRecord, + RunRecord, + RunStatus, +} from '@tanstack/ai-persistence' + +// --------------------------------------------------------------------------- +// Schema +// --------------------------------------------------------------------------- +// +// The canonical TanStack AI table layout: one row per thread transcript, plus +// run/interrupt lifecycle rows and a scoped key/value table. JSON payloads live +// in `text` columns (raw SQLite has no JSON column mode, so we serialize with +// `JSON.stringify`/`JSON.parse` at the edges). Timestamps are epoch milliseconds +// stored as `integer`. `CREATE TABLE IF NOT EXISTS` makes `migrate: true` +// idempotent — a real adapter would track versioned migrations instead. +const SCHEMA_SQL = ` +CREATE TABLE IF NOT EXISTS messages ( + thread_id text PRIMARY KEY NOT NULL, + messages_json text NOT NULL +); +CREATE TABLE IF NOT EXISTS 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 +); +CREATE TABLE IF NOT EXISTS 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 +); +CREATE TABLE IF NOT EXISTS metadata ( + scope text NOT NULL, + key text NOT NULL, + value_json text NOT NULL, + PRIMARY KEY (scope, key) +); +` + +// Row shapes as SQLite hands them back (JSON columns are still text here). +interface MessagesRow { + messages_json: string +} +interface RunRow { + run_id: string + thread_id: string + status: string + started_at: number + finished_at: number | null + error: string | null + usage_json: string | null +} +interface InterruptRow { + interrupt_id: string + run_id: string + thread_id: string + status: string + requested_at: number + resolved_at: number | null + payload_json: string + response_json: string | null +} +interface MetadataRow { + value_json: string +} + +function parseJson(text: string): T { + return JSON.parse(text) as T +} + +// --------------------------------------------------------------------------- +// MessageStore — full-transcript overwrite, keyed by thread. +// --------------------------------------------------------------------------- +function createMessageStore(db: DatabaseSync) { + const selectStmt = db.prepare( + 'SELECT messages_json FROM messages WHERE thread_id = ?', + ) + const upsertStmt = db.prepare( + `INSERT INTO messages (thread_id, messages_json) VALUES (?, ?) + ON CONFLICT(thread_id) DO UPDATE SET messages_json = excluded.messages_json`, + ) + return defineMessageStore({ + loadThread(threadId) { + const row = selectStmt.get(threadId) as MessagesRow | undefined + // INVARIANT: unknown thread returns [] (never null). + return Promise.resolve( + row ? parseJson>(row.messages_json) : [], + ) + }, + saveThread(threadId, messages) { + // Full replace, not append — `messages` is the authoritative history. + upsertStmt.run(threadId, JSON.stringify(messages)) + return Promise.resolve() + }, + }) +} + +// --------------------------------------------------------------------------- +// RunStore — idempotent create/resume + patch. +// --------------------------------------------------------------------------- +function mapRun(row: RunRow): RunRecord { + return { + runId: row.run_id, + threadId: row.thread_id, + status: row.status as RunStatus, + startedAt: row.started_at, + ...(row.finished_at != null ? { finishedAt: row.finished_at } : {}), + ...(row.error != null ? { error: row.error } : {}), + ...(row.usage_json != null + ? { usage: parseJson(row.usage_json) } + : {}), + } +} + +function createRunStore(db: DatabaseSync) { + const selectStmt = db.prepare('SELECT * FROM runs WHERE run_id = ?') + const insertStmt = db.prepare( + `INSERT INTO runs (run_id, thread_id, status, started_at) VALUES (?, ?, ?, ?) + ON CONFLICT(run_id) DO NOTHING`, + ) + const activeStmt = db.prepare( + `SELECT * FROM runs WHERE thread_id = ? AND status = 'running' + ORDER BY started_at DESC LIMIT 1`, + ) + return defineRunStore({ + createOrResume(input) { + // INVARIANT (idempotency): an existing run is returned unchanged; the + // insert is a no-op on conflict so startedAt/threadId/status never move. + const existing = selectStmt.get(input.runId) as RunRow | undefined + if (existing) return Promise.resolve(mapRun(existing)) + const status: RunStatus = input.status ?? 'running' + insertStmt.run(input.runId, input.threadId, status, input.startedAt) + const created = selectStmt.get(input.runId) as RunRow | undefined + return Promise.resolve( + created + ? mapRun(created) + : { + runId: input.runId, + threadId: input.threadId, + status, + startedAt: input.startedAt, + }, + ) + }, + update(runId, patch) { + // Build a dynamic SET from only the provided fields; empty patch is a + // no-op, and a missing run_id simply updates zero rows (no throw/create). + const sets: Array = [] + const params: Array = [] + if (patch.status !== undefined) { + sets.push('status = ?') + params.push(patch.status) + } + if (patch.finishedAt !== undefined) { + sets.push('finished_at = ?') + params.push(patch.finishedAt) + } + if (patch.error !== undefined) { + sets.push('error = ?') + params.push(patch.error) + } + if (patch.usage !== undefined) { + sets.push('usage_json = ?') + params.push(JSON.stringify(patch.usage)) + } + if (sets.length === 0) return Promise.resolve() + params.push(runId) + db.prepare(`UPDATE runs SET ${sets.join(', ')} WHERE run_id = ?`).run( + ...params, + ) + return Promise.resolve() + }, + get(runId) { + const row = selectStmt.get(runId) as RunRow | undefined + return Promise.resolve(row ? mapRun(row) : null) + }, + // The most recent still-running run for the thread, so `reconstructChat` + // reports `activeRun` and a hydrating client (reload / another device / + // switching back to this thread) tails it via the durability replay. Without + // this method the contract treats the thread as having no live run. + findActiveRun(threadId) { + const row = activeStmt.get(threadId) as RunRow | undefined + return Promise.resolve(row ? mapRun(row) : null) + }, + }) +} + +// --------------------------------------------------------------------------- +// InterruptStore — insert-if-absent, ordered listings. +// --------------------------------------------------------------------------- +function mapInterrupt(row: InterruptRow): InterruptRecord { + return { + interruptId: row.interrupt_id, + runId: row.run_id, + threadId: row.thread_id, + status: row.status as InterruptRecord['status'], + requestedAt: row.requested_at, + ...(row.resolved_at != null ? { resolvedAt: row.resolved_at } : {}), + payload: parseJson>(row.payload_json), + ...(row.response_json != null + ? { response: parseJson(row.response_json) } + : {}), + } +} + +function createInterruptStore(db: DatabaseSync) { + const insertStmt = db.prepare( + `INSERT INTO interrupts + (interrupt_id, run_id, thread_id, status, requested_at, payload_json, response_json) + VALUES (?, ?, ?, 'pending', ?, ?, ?) + ON CONFLICT(interrupt_id) DO NOTHING`, + ) + const resolveStmt = db.prepare( + `UPDATE interrupts SET status = 'resolved', resolved_at = ?, response_json = ? + WHERE interrupt_id = ?`, + ) + const cancelStmt = db.prepare( + `UPDATE interrupts SET status = 'cancelled', resolved_at = ? WHERE interrupt_id = ?`, + ) + const getStmt = db.prepare('SELECT * FROM interrupts WHERE interrupt_id = ?') + // Every listing is ORDER BY requested_at ASC — the middleware and testkit + // rely on this stable ordering. + const listByThreadStmt = db.prepare( + 'SELECT * FROM interrupts WHERE thread_id = ? ORDER BY requested_at ASC', + ) + const listPendingByThreadStmt = db.prepare( + `SELECT * FROM interrupts WHERE thread_id = ? AND status = 'pending' + ORDER BY requested_at ASC`, + ) + const listByRunStmt = db.prepare( + 'SELECT * FROM interrupts WHERE run_id = ? ORDER BY requested_at ASC', + ) + const listPendingByRunStmt = db.prepare( + `SELECT * FROM interrupts WHERE run_id = ? AND status = 'pending' + ORDER BY requested_at ASC`, + ) + const mapRows = (rows: Array): Array => + (rows as Array).map(mapInterrupt) + return defineInterruptStore({ + create(record) { + // Insert-if-absent: a duplicate id must never clobber an already-resolved + // interrupt back to pending. + insertStmt.run( + record.interruptId, + record.runId, + record.threadId, + record.requestedAt, + JSON.stringify(record.payload), + record.response === undefined ? null : JSON.stringify(record.response), + ) + return Promise.resolve() + }, + resolve(interruptId, response) { + resolveStmt.run( + Date.now(), + response === undefined ? null : JSON.stringify(response), + interruptId, + ) + return Promise.resolve() + }, + cancel(interruptId) { + cancelStmt.run(Date.now(), interruptId) + return Promise.resolve() + }, + get(interruptId) { + const row = getStmt.get(interruptId) as InterruptRow | undefined + return Promise.resolve(row ? mapInterrupt(row) : null) + }, + list(threadId) { + return Promise.resolve(mapRows(listByThreadStmt.all(threadId))) + }, + listPending(threadId) { + return Promise.resolve(mapRows(listPendingByThreadStmt.all(threadId))) + }, + listByRun(runId) { + return Promise.resolve(mapRows(listByRunStmt.all(runId))) + }, + listPendingByRun(runId) { + return Promise.resolve(mapRows(listPendingByRunStmt.all(runId))) + }, + }) +} + +// --------------------------------------------------------------------------- +// MetadataStore — scoped key/value JSON. +// --------------------------------------------------------------------------- +function assertStorableMetadata(value: unknown): void { + // SQL backends store JSON text in a NOT NULL column and cannot persist a + // nullish value. Reject it with a clear error (matching the sibling backends) + // instead of a cryptic driver failure; use `delete` to clear a value. + 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.`, + ) + } +} + +function createMetadataStore(db: DatabaseSync) { + const selectStmt = db.prepare( + 'SELECT value_json FROM metadata WHERE scope = ? AND key = ?', + ) + const upsertStmt = db.prepare( + `INSERT INTO metadata (scope, key, value_json) VALUES (?, ?, ?) + ON CONFLICT(scope, key) DO UPDATE SET value_json = excluded.value_json`, + ) + const deleteStmt = db.prepare( + 'DELETE FROM metadata WHERE scope = ? AND key = ?', + ) + return defineMetadataStore({ + get(scope, key) { + const row = selectStmt.get(scope, key) as MetadataRow | undefined + return Promise.resolve(row ? parseJson(row.value_json) : null) + }, + set(scope, key, value) { + assertStorableMetadata(value) + upsertStmt.run(scope, key, JSON.stringify(value)) + return Promise.resolve() + }, + delete(scope, key) { + deleteStmt.run(scope, key) + return Promise.resolve() + }, + }) +} + +export interface SqlitePersistenceOptions { + /** `:memory:`, a filesystem path, or a `file:`-prefixed filesystem path. */ + url: string + /** Create the TanStack AI tables on open (idempotent). */ + migrate?: boolean +} + +/** + * Build a `ChatPersistence` over a `node:sqlite` database. The returned object + * also exposes `close()` to release the file handle. + * + * Provides all four state stores (`messages`, `runs`, `interrupts`, + * `metadata`). Cross-worker coordination is a separate seam — a `LockStore` + * wired with `withLocks`, not a fifth entry in `stores`. + * + * Annotate the return as `ChatPersistence`, not bare `AIPersistence`: the + * unparameterized type is the all-optional store bag, and `withPersistence` + * rejects it because `stores.messages` is possibly `undefined`. + */ +export function sqlitePersistence( + options: SqlitePersistenceOptions, +): ChatPersistence & { close: () => void } { + const filename = normalizeSqliteUrl(options.url) + ensureParentDirectory(filename) + const db = new DatabaseSync(filename) + try { + if (options.migrate) db.exec(SCHEMA_SQL) + } catch (error) { + db.close() + throw error + } + + const persistence = defineAIPersistence({ + stores: { + messages: createMessageStore(db), + runs: createRunStore(db), + interrupts: createInterruptStore(db), + metadata: createMetadataStore(db), + }, + }) + + let closed = false + return { + ...persistence, + close() { + if (closed) return + db.close() + closed = true + }, + } +} + +// --------------------------------------------------------------------------- +// URL / path helpers (kept identical to the packaged Node SQLite factory so the +// `{ url, migrate }` call site is a drop-in). +// --------------------------------------------------------------------------- +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/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..ef58beb99 --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.persistent-chat.ts @@ -0,0 +1,268 @@ +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' +import { sendEmailTool } from '../lib/persistent-chat-tools' + +const persistence = persistentChatPersistence() + +// Delivery-layer producer dedup: run ids whose detached producer is currently +// pumping the memoryStream log. Prevents a duplicate POST (client retry, React +// strict-mode double render) from starting a second model call / double-writing +// the same log. Process-local, like the `memoryStream` log it guards — NOT the +// source of truth for "is this thread active" (that is the persistence runs +// store, resolved by `reconstructChat` via `findActiveRun(threadId)`). +const activeProducers = new Set() + +// 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 = 6, count = 1 }) => { + const rolls = Array.from( + { length: count }, + () => Math.floor(Math.random() * sides) + 1, + ) + return { rolls, total: rolls.reduce((sum, roll) => sum + roll, 0) } +}) + +// A human-in-the-loop tool. The DEFINITION is shared with the client (see +// `../lib/persistent-chat-tools`); here we attach the server implementation. +// `needsApproval` pauses the run on an interrupt before the tool runs; the +// interrupt is persisted by `withPersistence` and survives a reload — approve or +// reject after refreshing and the run resumes from exactly where it paused. +const sendEmail = sendEmailTool.server(({ to }) => { + // Demo: no real email is sent — the approval pause is the point. + return { + messageId: `msg-${Math.random().toString(36).slice(2, 10)}`, + to, + } +}) + +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. When the user ' + + 'asks to send an email, use the sendEmail tool (it pauses for their ' + + 'approval). Use tools rather than guessing, then summarize the result in a ' + + 'sentence.' + +/** + * 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. + */ +type ChatParams = Awaited> + +function startDetachedRun( + runId: string, + threadId: string, + messages: ChatParams['messages'], + // Present on the follow-up request after the user answers an interrupt: the + // parent (interrupted) run and the approval/rejection batch. Forwarded to + // chat() so it rebuilds the paused tool call and continues. + resume?: ChatParams['resume'], + parentRunId?: string, +): void { + if (activeProducers.has(runId)) return + activeProducers.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'), + // Reasoning on, effort "low" so it stays fast. `summary: 'auto'` asks OpenAI + // for a readable reasoning summary; when returned it streams as REASONING + // events, is persisted, and reconstructs like text/tool calls (the pane + // renders it as a "reasoning" block). NOTE: OpenAI only returns reasoning + // summary text to organizations verified for it — unverified keys still + // reason (you see reasoning tokens in usage) but get no summary to display. + modelOptions: { reasoning: { effort: 'low', summary: 'auto' } }, + // 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, sendEmail], + messages, + threadId, + runId, + ...(parentRunId ? { parentRunId } : {}), + ...(resume ? { resume } : {}), + // 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() + activeProducers.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 client runs server-authoritative (`persistence: true`) and keeps no + * messages, and no run pointer, of its own. On mount `useChat` + * hits this GET itself (keyed by threadId) and `reconstructChat` returns the + * transcript plus a cursor to any in-flight run, which the client then tails via + * the delivery replay branch below. No loader, no client hydration code. + */ + +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. + // `resume`/`parentRunId` are set on the follow-up after an interrupt is + // answered, so the paused tool call continues. + startDetachedRun( + params.runId, + params.threadId, + params.messages, + params.resume, + params.parentRunId, + ) + + // This reader genuinely races the producer it just started, so give it a + // generous first-chunk deadline (the default is tuned short for reload + // rejoins, where the producer ran in a prior request and an empty log + // means "gone"). + const reader = memoryStream( + new Request( + `http://persistent-chat.internal/?offset=-1&runId=${encodeURIComponent(params.runId)}`, + ), + { firstChunkDeadlineMs: 10_000 }, + ) + 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: true`) fetches on reload, since the delivery log only + // holds one run, never prior turns. + GET: ({ request }) => { + // `memoryStream` fails a from-start join to a gone/empty run fast (its + // ~100ms default first-chunk deadline), so an unresumable reload frees + // the input near-instantly instead of hanging. Raise + // `firstChunkDeadlineMs` here if your producer can start well after a + // joiner attaches. + const durability = memoryStream(request) + if (durability.resumeFrom() !== null) { + return resumeServerSentEventsResponse({ adapter: durability }) + } + // Demo only: single shared thread, no multi-user auth. Production routes + // must authorize ownership (session → allowed thread ids) before load — + // without `authorize`, any caller who knows `?threadId=` gets the full + // transcript. + return reconstructChat(persistence, request, { + authorize: async (threadId) => { + // e.g. return (await getSessionUser())?.canAccess(threadId) ?? false + return threadId.length > 0 + }, + }) + }, + }, + }, +}) diff --git a/examples/ts-react-chat/src/routes/example.runtime-context.tsx b/examples/ts-react-chat/src/routes/example.runtime-context.tsx index 39609050c..5a0366fa7 100644 --- a/examples/ts-react-chat/src/routes/example.runtime-context.tsx +++ b/examples/ts-react-chat/src/routes/example.runtime-context.tsx @@ -283,7 +283,7 @@ function RuntimeContextExamplePage() { ], ) const { messages, sendMessage, isLoading, error, stop } = useChat({ - id: 'runtime-context-example', + threadId: 'runtime-context-example', connection: fetchServerSentEvents('/api/tanchat'), tools: runtimeContextTools, context: runtimeContext, diff --git a/examples/ts-react-chat/src/routes/generations.structured-output.tsx b/examples/ts-react-chat/src/routes/generations.structured-output.tsx index 1568a6b83..a063fd6ce 100644 --- a/examples/ts-react-chat/src/routes/generations.structured-output.tsx +++ b/examples/ts-react-chat/src/routes/generations.structured-output.tsx @@ -247,7 +247,7 @@ function StructuredOutputPage() { } const chat = useChat({ - id: 'structured-output:useChat', + threadId: 'structured-output:useChat', outputSchema: GuitarRecommendationSchema, connection: fetchServerSentEvents('/api/structured-output'), forwardedProps: { provider, model, stream }, diff --git a/examples/ts-react-chat/src/routes/interrupts.tsx b/examples/ts-react-chat/src/routes/interrupts.tsx index ecb18003a..987db3aa1 100644 --- a/examples/ts-react-chat/src/routes/interrupts.tsx +++ b/examples/ts-react-chat/src/routes/interrupts.tsx @@ -75,7 +75,6 @@ function SanctuaryPage() { setDecisions((prev) => [message, ...prev].slice(0, 8)) const chat = useChat({ - id: threadId, threadId, connection, tools: clientTools, diff --git a/examples/ts-react-chat/src/routes/issue-176-tool-result.tsx b/examples/ts-react-chat/src/routes/issue-176-tool-result.tsx index 9acd3e5a0..130275f94 100644 --- a/examples/ts-react-chat/src/routes/issue-176-tool-result.tsx +++ b/examples/ts-react-chat/src/routes/issue-176-tool-result.tsx @@ -47,7 +47,7 @@ function Issue176ToolResultRepro() { ) const { messages: fixtureMessages } = useChat({ - id: 'issue-176-tool-result-repro', + threadId: 'issue-176-tool-result-repro', connection: fetchServerSentEvents('/api/tanchat'), initialMessages, }) @@ -57,12 +57,12 @@ function Issue176ToolResultRepro() { isLoading, error, } = useChat({ - id: 'issue-176-live-tool-result-repro', + threadId: 'issue-176-live-tool-result-repro', connection: fetchServerSentEvents('/api/tanchat'), tools: liveTools, body: { provider: 'openai', - model: 'gpt-4o', + model: 'gpt-5.5', }, }) 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..427df1a7d --- /dev/null +++ b/examples/ts-react-chat/src/routes/persistent-chat.css @@ -0,0 +1,359 @@ +.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; + + width: 100%; + /* Fill the viewport below the app header (h-72px / p-4 logo bar). */ + height: calc(100dvh - 72px); + margin: 0; + display: flex; + flex-direction: row; + color: var(--pc-text); + background: var(--pc-bg); + border: none; + border-radius: 0; + overflow: hidden; + font-family: + ui-sans-serif, + system-ui, + -apple-system, + 'Segoe UI', + Roboto, + sans-serif; +} + +.pc-sidebar { + width: 240px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px 12px; + border-right: 1px solid var(--pc-border); + background: var(--pc-panel); + overflow: hidden; +} + +.pc-new { + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--pc-border); + background: var(--pc-accent); + color: #fff; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} +.pc-new:hover { + filter: brightness(1.08); +} + +.pc-threadlist { + display: flex; + flex-direction: column; + gap: 4px; + overflow-y: auto; +} + +.pc-threaditem { + text-align: left; + padding: 9px 11px; + border-radius: 9px; + border: 1px solid transparent; + background: transparent; + color: var(--pc-muted); + font-size: 13px; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.pc-threaditem:hover { + background: var(--pc-panel-2); + color: var(--pc-text); +} +.pc-threaditem.active { + background: var(--pc-panel-2); + border-color: var(--pc-accent); + color: var(--pc-text); +} + +.pc-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + padding: 22px 22px 0; + overflow: hidden; +} + +.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-reasoning { + align-self: stretch; + border-left: 2px solid var(--pc-border); + padding: 4px 0 4px 12px; + font-size: 13px; + line-height: 1.5; + color: var(--pc-muted); + font-style: italic; + white-space: pre-wrap; + word-break: break-word; +} +.pc-reasoning-label { + display: block; + font-size: 10.5px; + font-style: normal; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--pc-muted); + opacity: 0.7; + margin-bottom: 3px; +} + +.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-approval { + margin: 0 2px 12px; + padding: 12px 14px; + border: 1px solid var(--pc-accent); + border-radius: 12px; + background: rgba(99, 102, 241, 0.08); +} +.pc-approval-head { + font-size: 14px; + margin-bottom: 8px; +} +.pc-approval-actions { + display: flex; + gap: 8px; + margin-top: 10px; +} +.pc-approve, +.pc-reject { + padding: 7px 16px; + border-radius: 9px; + border: 1px solid var(--pc-border); + font-size: 13px; + font-weight: 600; + cursor: pointer; +} +.pc-approve { + background: #22c55e; + color: #05240f; + border-color: #22c55e; +} +.pc-reject { + background: transparent; + color: var(--pc-text); +} +.pc-approve:disabled, +.pc-reject:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.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..4927e537d --- /dev/null +++ b/examples/ts-react-chat/src/routes/persistent-chat.tsx @@ -0,0 +1,328 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useEffect, useRef, useState } from 'react' +import { fetchServerSentEvents } from '@tanstack/ai-client' +import { useChat } from '@tanstack/ai-react' +import { sendEmailTool } from '../lib/persistent-chat-tools' +import './persistent-chat.css' + +export const Route = createFileRoute('/persistent-chat')({ + component: PersistentChatPage, +}) + +// One SSE connection serves every thread: useChat keys persistence and the +// server GET (?threadId) on the thread id, so switching threads reuses it. +const connection = fetchServerSentEvents('/api/persistent-chat') + +// Server-authoritative persistence: the client caches nothing. On mount useChat +// hydrates a thread from the server by its id (the stored transcript plus a +// cursor to any run still generating), so switching threads, reloading, or +// opening a thread on another device all just resume. The server (SQLite) owns +// every conversation. +const persistence = true + +// Share the tool DEFINITION with the client via an argless `.client()`: the +// browser gets no implementation (the server runs sendEmail), but it learns the +// tool's approval shape so it can bind the pending approval interrupt and type +// it as `tool-approval`. Defined once at module scope so the array identity is +// stable across renders. +const chatTools = [sendEmailTool.client()] as const + +// The thread INDEX (which threads exist and their titles) is small app state, +// kept in localStorage. The transcripts themselves live on the server, one per +// thread id — this list is only how the UI enumerates and labels them. +const THREADS_KEY = 'persistent-chat:threads' + +interface Thread { + id: string + title: string +} + +function loadThreads(): Array { + if (typeof window === 'undefined') return [] + try { + const raw = window.localStorage.getItem(THREADS_KEY) + const parsed: unknown = raw ? JSON.parse(raw) : null + return Array.isArray(parsed) ? (parsed as Array) : [] + } catch { + return [] + } +} + +function newThread(): Thread { + return { id: `thread-${crypto.randomUUID()}`, title: 'New chat' } +} + +const SUGGESTIONS = [ + "What's the weather in Tokyo?", + 'Roll two 20-sided dice.', + 'Write a long 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() { + const [threads, setThreads] = useState>([]) + const [activeId, setActiveId] = useState(null) + const [hydrated, setHydrated] = useState(false) + + // Load the index on the client (localStorage is not available during SSR, so + // the first render is empty on both sides — no hydration mismatch). Start a + // thread if there are none, so there is always an active conversation. + useEffect(() => { + const loaded = loadThreads() + if (loaded.length === 0) { + const first = newThread() + setThreads([first]) + setActiveId(first.id) + } else { + setThreads(loaded) + setActiveId(loaded[0]!.id) + } + setHydrated(true) + }, []) + + useEffect(() => { + if (!hydrated) return + window.localStorage.setItem(THREADS_KEY, JSON.stringify(threads)) + }, [threads, hydrated]) + + const createThread = () => { + const thread = newThread() + setThreads((prev) => [thread, ...prev]) + setActiveId(thread.id) + } + + // Name a thread after its first user message so the sidebar is readable. + // A no-op (returns the same array) once the thread already has a title, so it + // never churns state. + const titleFromFirstMessage = (id: string, title: string) => { + setThreads((prev) => { + const current = prev.find((t) => t.id === id) + if (!current || current.title !== 'New chat') return prev + return prev.map((t) => (t.id === id ? { ...t, title } : t)) + }) + } + + return ( +
+ + +
+ {activeId ? ( + titleFromFirstMessage(activeId, text)} + /> + ) : null} +
+
+ ) +} + +function ChatPane({ + threadId, + onFirstMessage, +}: { + threadId: string + onFirstMessage: (text: string) => void +}) { + // Keyed by threadId in the parent, so a fresh instance mounts per thread and + // hydrates that thread from the server. Nothing to wire beyond threadId. + const { + messages, + sendMessage, + isLoading, + connectionStatus, + interrupts, + resuming, + } = useChat({ + threadId, + connection, + persistence, + // Share the tool definition so the client can bind the approval interrupt + // (verify its schema hashes) and resolve it. + tools: chatTools, + }) + const [input, setInput] = useState('') + const threadRef = useRef(null) + + useEffect(() => { + threadRef.current?.scrollTo({ top: threadRef.current.scrollHeight }) + }, [messages]) + + useEffect(() => { + const firstUser = messages.find((m) => m.role === 'user') + const part = firstUser?.parts.find((p) => p.type === 'text' && p.content) + if (part && 'content' in part && typeof part.content === 'string') { + onFirstMessage(part.content.slice(0, 40)) + } + }, [messages, onFirstMessage]) + + const send = (text: string) => { + const trimmed = text.trim() + if (!trimmed || isLoading) return + setInput('') + void sendMessage(trimmed) + } + + return ( + <> +
+

Persistent chat

+ + + {connectionStatus} + +
+ +

+ The server owns every conversation (transcript, runs, interrupts, tool + calls) in SQLite via withPersistence. The client caches + nothing (persistence: true); on mount useChat{' '} + hydrates this thread from the server by its id, no loader, no props. + Start a long reply, then switch threads or reload: it resumes exactly + where it was. +

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

+ No messages yet — try a suggestion below, then reload or switch + threads to see it resume. +

+ ) : ( + messages.map((message) => ( +
+ {message.role} + {message.parts.map((part, index) => { + const key = `${message.id}-${index}` + if (part.type === 'thinking' && part.content) { + return ( +
+ reasoning + {part.content} +
+ ) + } + 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 + })} +
+ )) + )} +
+ + {interrupts.map((interrupt) => + interrupt.kind === 'tool-approval' ? ( +
+
+ 🔐 Approve {interrupt.toolName}? +
+
+              {formatValue(interrupt.originalArgs)}
+            
+
+ + +
+
+ ) : 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/examples/ts-react-chat/src/routes/threads.tsx b/examples/ts-react-chat/src/routes/threads.tsx index 5ab90294e..8889cd67f 100644 --- a/examples/ts-react-chat/src/routes/threads.tsx +++ b/examples/ts-react-chat/src/routes/threads.tsx @@ -355,20 +355,16 @@ const CHAT_BODY = { provider: 'openrouter', model: 'openai/gpt-5.1' } as const const REHYPE_PLUGINS = [rehypeRaw, rehypeSanitize, rehypeHighlight] const REMARK_PLUGINS = [remarkGfm] -/** Render a single UIMessage part: reasoning, text, approval prompt, or guitar card. */ +/** Render a single UIMessage part: reasoning, text, or guitar card. Approval + * prompts are surfaced separately from the `interrupts` array (see ThreadChat). */ function MessagePart({ part, index, message, - addToolApprovalResponse, }: { part: UIMessage['parts'][number] index: number message: UIMessage - addToolApprovalResponse: (response: { - id: string - approved: boolean - }) => Promise }) { if (part.type === 'thinking') { // "Complete" once any text part follows it in the same message. @@ -397,44 +393,6 @@ function MessagePart({ ) } - if ( - part.type === 'tool-call' && - part.state === 'approval-requested' && - part.approval - ) { - return ( -
-

- 🔒 Approval required: {part.name} -

-
-          {JSON.stringify(JSON.parse(part.arguments), null, 2)}
-        
-
- - -
-
- ) - } - if ( part.type === 'tool-call' && part.name === 'recommendGuitar' && @@ -460,10 +418,11 @@ function ThreadChat({ sendMessage, isLoading, error, - addToolApprovalResponse, + interrupts, + resuming, stop, - } = useChat({ - id: threadId, + } = useChat({ + threadId, connection: fetchServerSentEvents('/api/tanchat'), persistence: threadPersistence, tools, @@ -522,12 +481,42 @@ function ThreadChat({ part={part} index={index} message={message} - addToolApprovalResponse={addToolApprovalResponse} /> ))} ))} + {interrupts.map((interrupt) => + interrupt.kind === 'tool-approval' ? ( +
+

+ 🔒 Approval required: {interrupt.toolName} +

+
+                  {JSON.stringify(interrupt.originalArgs, null, 2)}
+                
+
+ + +
+
+ ) : null, + )} {error && (
{error.message} diff --git a/examples/ts-react-chat/src/routes/typesafe-tools.tsx b/examples/ts-react-chat/src/routes/typesafe-tools.tsx index dbd4610d9..39e49a818 100644 --- a/examples/ts-react-chat/src/routes/typesafe-tools.tsx +++ b/examples/ts-react-chat/src/routes/typesafe-tools.tsx @@ -23,7 +23,7 @@ function TypesafeToolsPage() { const [prompt, setPrompt] = useState('Recommend me an acoustic guitar.') const { messages, sendMessage, isLoading, error } = useChat({ - id: 'typesafe-tools-bare-array', + threadId: 'typesafe-tools-bare-array', connection: fetchServerSentEvents('/api/tanchat'), body: { provider: 'openai', model: 'gpt-5.5' }, // 👇 Bare array literal — no clientTools(), no `as const`. diff --git a/examples/ts-react-chat/vitest.config.ts b/examples/ts-react-chat/vitest.config.ts new file mode 100644 index 000000000..843cdfef1 --- /dev/null +++ b/examples/ts-react-chat/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config' + +// A minimal Node-environment vitest config for the example's unit tests (e.g. +// the SQLite persistence conformance suite). It deliberately does NOT load the +// TanStack Start / Nitro plugins from `vite.config.ts` — these tests exercise +// plain server-side modules and need Node's `node:sqlite`, not the app bundle. +export default defineConfig({ + test: { + name: 'ts-react-chat', + environment: 'node', + // Scoped to the persistence demo. Other `*.test.ts` files under `src/` + // predate this and were never wired to a `test:lib` target; leave their + // (dormant) status unchanged rather than silently activating them here. + include: ['src/lib/**/*.test.ts'], + watch: false, + }, +}) diff --git a/examples/ts-react-native-chat/src/App.tsx b/examples/ts-react-native-chat/src/App.tsx index 9828fbbaf..973bf42b2 100644 --- a/examples/ts-react-native-chat/src/App.tsx +++ b/examples/ts-react-native-chat/src/App.tsx @@ -451,7 +451,7 @@ export default function App() { clear, sessionGenerating, } = useChat({ - id: `rn-recipe-book-${transport}`, + threadId: `rn-recipe-book-${transport}`, connection, }) diff --git a/packages/ai-angular/src/index.ts b/packages/ai-angular/src/index.ts index 4f9565c58..2c8dbef90 100644 --- a/packages/ai-angular/src/index.ts +++ b/packages/ai-angular/src/index.ts @@ -77,6 +77,16 @@ export type { // Re-export from @tanstack/ai-client for convenience export { fetchServerSentEvents, + localStoragePersistence, + sessionStoragePersistence, + indexedDBPersistence, + StorageUnavailableError, + type ChatClientPersistence, + type ChatPersistedState, + 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 c55cfd7b4..4904be8e1 100644 --- a/packages/ai-client/src/chat-client.ts +++ b/packages/ai-client/src/chat-client.ts @@ -24,6 +24,7 @@ import type { StreamChunk, } from '@tanstack/ai/client' import type { + ChatHydrationResult, ConnectionAdapter, SubscribeConnectionAdapter, } from './connection-adapters' @@ -240,6 +241,28 @@ function readResumeState( return { threadId: resumeState.threadId, runId: resumeState.runId } } +/** + * How long a reload rejoin waits for its first chunk before giving up. A durable + * backend keeps a from-start join open waiting for a producer; without this + * bound a stale pointer to an unknown/evicted run would pin the UI loading for + * the backend's full first-chunk deadline (tens of seconds). Kept short so the + * client decides "reachable or not" quickly. + */ +const REJOIN_CONNECT_DEADLINE_MS = 2000 + +/** + * Chunk types that (re)build the assistant message on a rejoin. The hydrated + * in-flight partial is dropped only when one of these arrives — never on + * `RUN_STARTED` alone — so a rejoin that connects but delivers no content cannot + * leave an empty assistant bubble. + */ +const REJOIN_REBUILD_TRIGGERS = new Set([ + 'TEXT_MESSAGE_START', + 'TEXT_MESSAGE_CONTENT', + 'TOOL_CALL_START', + 'MESSAGES_SNAPSHOT', +]) + export class ChatClient< TTools extends ReadonlyArray = any, TContext = unknown, @@ -248,9 +271,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 +283,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: @@ -368,13 +397,33 @@ export class ChatClient< } constructor(options: ChatClientOptions) { - this.uniqueId = options.id || this.generateUniqueId('chat') this.threadId = options.threadId || this.generateUniqueId('thread') - if (options.persistence) { + // The instance/devtools id defaults to the threadId (the chat's identity), + // falling back to a generated id only when neither is set. `id` overrides it + // only for direct ChatClient users who key storage separately from the wire + // thread; the framework hooks never pass it. + this.uniqueId = options.id || this.threadId + // `persistence` is `false`/omitted (ephemeral, in-memory), `true` + // (server-authoritative: cache nothing client-side, hydrate the thread from + // the server by `threadId` on mount), or a storage adapter + // (client-authoritative: cache the transcript plus resume pointer). Only the + // server-authoritative mode turns transcript caching off; that is what gates + // the mount hydration and keeps a client record from shadowing server history. + let cachesMessages = true + if (options.persistence === true) { + cachesMessages = false + } else if (options.persistence) { + // A storage adapter: keep the combined record (transcript + resume pointer) + // in the browser. 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, + persistenceKey, (messages) => this.processor.setMessages(messages), + (snapshot) => this.applyPersistedResume(snapshot), ) } // Both `body` (deprecated) and `forwardedProps` populate the AG-UI @@ -439,10 +488,49 @@ 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 + const persistedState = this.persistor?.readInitial() + const syncPersistedState = + persistedState instanceof Promise ? undefined : persistedState + // A persistor exists only in client-authoritative mode, so a synchronously + // read record's transcript is the conversation; adopt it over host + // `initialMessages`. (Server-authoritative mode has no persistor and instead + // hydrates from the server on mount, keyed by threadId.) + const initialMessages = 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) { + // Interrupts are run-scoped state, restored from the cached snapshot. + this.applyResumeSnapshot(snapshot) + } else if (snapshot.resumeState.runId) { + // A bare in-flight run pointer drives a client-authoritative rejoin. + rejoinRunId = snapshot.resumeState.runId + } + } + // A host-supplied `initialResumeSnapshot` carrying a bare in-flight run is + // rejoined too, not just its interrupts (which `applyResumeSnapshot` above + // already restored). This is how a server-authoritative app hands a FRESH + // client an in-flight run to tail — e.g. opening the thread on a second + // device / browser, where hydration reports the active run id but no local + // resume pointer exists. A run named by the persisted store wins. + if (!rejoinRunId && options.initialResumeSnapshot) { + const snapshot = options.initialResumeSnapshot + const hasPendingInterrupts = + Array.isArray(snapshot.pendingInterrupts) && + snapshot.pendingInterrupts.length > 0 + if (!hasPendingInterrupts && snapshot.resumeState.runId) { + rejoinRunId = snapshot.resumeState.runId + } + } this.processor = new StreamProcessor({ ...(options.streamProcessor?.chunkStrategy @@ -656,7 +744,24 @@ 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) + } + + // Server-authoritative (`persistence: true`): the client caches no transcript + // and no run pointer — it re-hydrates from the server on mount, keyed by the + // stable threadId. `hydrate` returns the stored transcript plus a cursor to + // any in-flight run, which is tailed via the same joinRun path. This is what + // makes reload AND a fresh device work with zero app glue (no loader/prop). + if (!cachesMessages && this.connection.hydrate) { + this.hydrateFromServer() + } } private applyResumeSnapshot(snapshot: ChatResumeSnapshot): void { @@ -682,6 +787,92 @@ 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 + // A cached run pointer only reaches here through the persistor, which exists + // only in client-authoritative mode, so a bare in-flight run rejoins here. + // (Server-authoritative reconnect is resolved from the server by threadId in + // `hydrateFromServer`.) + 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) + } + + /** + * Server-authoritative mount hydration (`persistence: true`). The client holds + * no transcript and no run pointer; on mount it asks the server — keyed by the + * stable threadId — for the stored transcript and whether a run is still + * generating. The transcript repaints immediately; an in-flight run is tailed + * through the same durability rejoin as a reload. Best-effort and + * non-blocking: a failure leaves the client empty rather than throwing, and a + * send that starts first owns the client (hydration then backs off). + */ + private hydrateFromServer(): void { + const hydrate = this.connection.hydrate + if (!hydrate) return + if (this.isLoading || this.abortController) return + void (async () => { + let result: ChatHydrationResult + try { + result = await hydrate(this.threadId) + } catch { + return + } + // A send may have started while the fetch was in flight — don't stomp it. + if (this.isLoading || this.abortController) return + if (result.messages.length > 0) { + this.processor.setMessages(result.messages) + } + if (result.interrupts && result.interrupts.pending.length > 0) { + // Pending interrupt = the thread is paused awaiting a human decision, so + // there is nothing to tail (no chunks stream until it resolves). Restore + // the approval/wait from the SERVER — identical to reconstructing it from + // a resume snapshot — so the reload re-prompts the decision and the resume + // targets the run it paused. This is checked BEFORE `activeRun` on + // purpose: a run that just paused can momentarily still read as `running` + // on the server, so a racing hydrate reports both an `activeRun` cursor + // AND the pending interrupt. Tailing that "active" run would drop the + // approval card (and hang on a stream that never comes), so the interrupt + // always wins. + this.applyResumeSnapshot({ + schemaVersion: 2, + resumeState: { + threadId: this.threadId, + runId: result.interrupts.runId, + }, + pendingInterrupts: result.interrupts.pending, + }) + } else if (result.activeRun?.runId) { + this.maybeRejoinInFlight(result.activeRun.runId) + } + })() + } + mountDevtools(): void { if (this.devtoolsMounted) { return @@ -731,6 +922,19 @@ 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 a persistor writes it, and a persistor + // exists only in client-authoritative mode; server-authoritative reconnect + // is resolved from the server by threadId in `hydrateFromServer`, so no + // client-cached run pointer (which goes stale the moment a turn spans a + // second run) is ever written. 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 } @@ -828,6 +1032,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 } @@ -1024,6 +1231,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(), @@ -1033,6 +1244,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 { @@ -1193,7 +1424,112 @@ 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 chunk that actually + * (re)builds a message we drop the hydrated in-flight assistant, and the + * replay reconstructs one clean bubble. Dropping only on real content (not on + * `RUN_STARTED`) means a rejoin that connects but delivers nothing can never + * leave an empty bubble behind. + * + * Bounded connect: a durable backend keeps a from-start join open waiting for + * a producer, so a stale pointer to an unknown/evicted run would otherwise pin + * the UI in a loading state for the backend's full first-chunk deadline. We + * give up after {@link REJOIN_CONNECT_DEADLINE_MS} if no chunk arrives and + * clear the dead pointer so it does not retry on the next load. + * + * 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 + // Record the resume state in-memory BEFORE replaying. Otherwise the + // replayed `RUN_STARTED` (which carries the PROVIDER run id, not the + // client/durability-log run id the pointer is keyed by) trips the + // `!this.lastResume` guard in `updateRunLifecycle` and rewrites the + // persisted pointer with the provider id — so a SECOND reload would + // `joinRun` an id the log isn't keyed by and never re-attach. + this.lastResume = { threadId: this.threadId, runId } + this.setIsLoading(true) + this.setStatus('streaming') + void (async () => { + let rebuilt = false + let attached = false + const connectTimer = setTimeout(() => { + if (!attached) controller.abort() + }, REJOIN_CONNECT_DEADLINE_MS) + try { + for await (const chunk of joinRun(runId, controller.signal)) { + if (controller.signal.aborted) break + if (!attached) { + attached = true + clearTimeout(connectTimer) + } + if (!rebuilt && REJOIN_REBUILD_TRIGGERS.has(chunk.type)) { + rebuilt = true + this.dropTrailingInFlightAssistant() + } + await this.processIncomingChunk(chunk, { defer: false }) + } + } catch (error) { + // Pre-attach failures (unknown/evicted run, connect deadline abort) + // stay soft: keep the restored transcript and clear the dead pointer + // in `finally`. Post-attach transport/parser failures are real stream + // errors and must surface so the UI is not left truncated and silent. + const isAbort = + error instanceof Error && + (error.name === 'AbortError' || error.name === 'TimeoutError') + if (attached && !isAbort) { + this.reportStreamError( + error instanceof Error ? error : new Error(String(error)), + ) + } + } finally { + clearTimeout(connectTimer) + if (!attached) { + // Never reached the run (unknown / evicted / unreachable in time): the + // pointer is dead. Clear it so it does not retry and re-pin the UI on + // the next load. The server's persisted transcript is still loaded. + this.lastResume = null + this.persistor?.persistResumeSnapshot(null) + } + 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) @@ -1223,7 +1559,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..b71610cde 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 @@ -30,10 +44,12 @@ function getChunkParentMessageId(chunk: StreamChunk): string | undefined { * * Two responsibilities live here: * - * 1. **Storage orchestration** — hydrate from `getItem(id)` on creation, save to - * `setItem(id, messages)` on every change through an ordered write queue, and - * `removeItem(id)` on clear. A generation counter discards stale writes when a - * removal or a newer conversation supersedes an in-flight async operation. + * 1. **Storage orchestration** — hydrate from `getItem(id)` on creation, save a + * combined `{ messages, resume? }` record via `setItem` on every change + * through an ordered write queue, and `removeItem(id)` on clear (or when + * both the transcript and resume pointer are empty). A generation counter + * discards stale writes when a removal or a newer conversation supersedes + * an in-flight async operation. * 2. **Clear-during-stream suppression** — when a conversation is cleared while a * stream is still producing, late chunks for the cleared run(s) must not * repopulate the now-empty state. The persistor tracks the cleared ids and @@ -51,6 +67,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 +83,91 @@ export class ChatPersistor { private readonly adapter: ChatClientPersistence, private readonly id: string, private readonly applyMessages: (messages: Array) => void, + private readonly applyResume?: (snapshot: ChatResumeSnapshot) => void, ) {} + /** Persist the current state as one combined `{ messages, resume? }` record. */ + private writeState(): void { + const messages = [...this.lastMessages] + // Nothing to persist (no transcript, no resume pointer): remove the key + // rather than writing an empty `{ messages: [] }`, so a cleared + // conversation does not leave a stale record behind. + if (messages.length === 0 && !this.lastResume) { + const generation = this.generation + this.runOperation(() => { + if (generation !== this.generation) { + return + } + return this.adapter.removeItem(this.id) + }) + 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 + this.lastMessages = state.messages + this.applyMessages(state.messages) + if (state.resume && this.applyResume) { + this.applyResume(state.resume) } }) .catch(() => { @@ -116,28 +176,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..260f17526 100644 --- a/packages/ai-client/src/connection-adapters.ts +++ b/packages/ai-client/src/connection-adapters.ts @@ -12,7 +12,7 @@ import type { StreamChunk, UIMessage, } from '@tanstack/ai/client' -import type { ChatFetcher } from './types' +import type { ChatFetcher, ChatPendingInterrupt } from './types' /** * Associates connect-wrapped chunks with the run they were produced under. @@ -407,6 +407,52 @@ function assertResponseOk(response: Response): void { } } +/** + * GET the hydration endpoint for a thread and parse its JSON `{ messages, + * activeRun }` body. This is the transport-agnostic reconnect probe: keyed on + * the STABLE thread id, it returns the stored transcript and — if a run is still + * generating — a cursor the caller tails via `joinRun`. Shared by every fetch/ + * XHR adapter so the client never has to know which transport is in use. + */ +async function fetchThreadHydration( + fetchClient: typeof globalThis.fetch, + url: string, + headers: Record, + credentials: RequestCredentials, + threadId: string, +): Promise { + const response = await fetchClient(withSearchParams(url, { threadId }), { + method: 'GET', + headers: { Accept: 'application/json', ...headers }, + credentials, + }) + assertResponseOk(response) + const data = (await response.json()) as { + messages?: Array + activeRun?: { runId?: unknown } | null + interrupts?: { runId?: unknown; pending?: unknown } | null + } + const activeRun = + data.activeRun && typeof data.activeRun.runId === 'string' + ? { runId: data.activeRun.runId } + : null + const interrupts = + data.interrupts && + typeof data.interrupts.runId === 'string' && + Array.isArray(data.interrupts.pending) && + data.interrupts.pending.length > 0 + ? { + runId: data.interrupts.runId, + pending: data.interrupts.pending as Array, + } + : null + return { + messages: Array.isArray(data.messages) ? data.messages : [], + activeRun, + interrupts, + } +} + /** Yield SSE stream events (chunk + offset) from a fetch Response body. */ async function* responseToSSEEvents( response: Response, @@ -663,6 +709,23 @@ export interface ConnectConnectionAdapter { ) => AsyncIterable } +/** + * Server-resolved hydration for a thread. `messages` is the stored transcript; + * `activeRun` is a cursor to a run still generating for the thread (or `null`). + * Keyed on the STABLE thread id — the client never handles a run id, so a turn + * that spans several runs (interrupt/tool continuations) reconnects correctly. + */ +export interface ChatHydrationResult { + messages: Array + activeRun: { runId: string } | null + /** + * Pending human-in-the-loop interrupts for the thread and the run they paused, + * so a reload (or another device) re-prompts the approval from the server. The + * client restores them exactly as a persisted resume snapshot would. + */ + interrupts: { runId: string; pending: Array } | null +} + /** * A {@link ConnectConnectionAdapter} that also supports joining an existing run * (a second tab, or re-attaching after a full reload) via `joinRun`, replaying @@ -677,6 +740,14 @@ export interface ResumableConnectConnectionAdapter extends ConnectConnectionAdap runId: string, abortSignal?: AbortSignal, ) => AsyncIterable + /** + * Fetch server-authoritative hydration for `threadId`: the stored transcript, + * and a cursor to an in-flight run if one exists. The client calls this itself + * on mount (no loader/prop), then tails `activeRun` via `joinRun`. Read-only + * JSON GET (`?threadId`), so it is transport-agnostic regardless of how the + * delivery stream is served. + */ + hydrate?: (threadId: string) => Promise } export interface SubscribeConnectionAdapter { @@ -693,6 +764,22 @@ 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 + /** + * Server-authoritative hydration for a thread (transcript + in-flight-run + * cursor). Present only when the underlying connection supports it. The client + * calls it on mount to re-hydrate without any app-side loader or prop. + */ + hydrate?: (threadId: string) => Promise } /** @@ -727,9 +814,17 @@ export function normalizeConnectionAdapter( } if (hasSubscribe && hasSend) { + const joinRun = (connection as SubscribeConnectionAdapter).joinRun?.bind( + connection, + ) + const hydrate = (connection as SubscribeConnectionAdapter).hydrate?.bind( + connection, + ) return { subscribe: connection.subscribe.bind(connection), send: connection.send.bind(connection), + ...(joinRun ? { joinRun } : {}), + ...(hydrate ? { hydrate } : {}), } } @@ -858,6 +953,28 @@ export function normalizeConnectionAdapter( throw err } }, + // Expose joinRun only when the underlying connection is resumable. Require + // a real function — `'joinRun' in connection` is true for + // `{ joinRun: undefined }`, which would wrap a non-callable and throw on + // rehydration rejoin. + ...(typeof (connection as ResumableConnectConnectionAdapter).joinRun === + 'function' + ? { + joinRun: (runId: string, abortSignal?: AbortSignal) => + (connection as ResumableConnectConnectionAdapter).joinRun( + runId, + abortSignal, + ), + } + : {}), + ...(() => { + // Capture under the typeof guard so `hydrate` narrows to the function type + // (no non-null assertion). Present only when the connection supports it. + const hydrate = (connection as ResumableConnectConnectionAdapter).hydrate + return typeof hydrate === 'function' + ? { hydrate: (threadId: string) => hydrate(threadId) } + : {} + })(), } } @@ -1068,6 +1185,18 @@ export function fetchServerSentEvents( resolvedOptions.reconnect, ) }, + async hydrate(threadId) { + const resolvedUrl = typeof url === 'function' ? url() : url + const resolvedOptions = + typeof options === 'function' ? await options() : options + return fetchThreadHydration( + resolvedOptions.fetchClient ?? fetch, + resolvedUrl, + mergeHeaders(resolvedOptions.headers), + resolvedOptions.credentials || 'same-origin', + threadId, + ) + }, } } @@ -1199,6 +1328,18 @@ export function fetchHttpStream( resolvedOptions.reconnect, ) }, + async hydrate(threadId) { + const resolvedUrl = typeof url === 'function' ? url() : url + const resolvedOptions = + typeof options === 'function' ? await options() : options + return fetchThreadHydration( + resolvedOptions.fetchClient ?? fetch, + resolvedUrl, + mergeHeaders(resolvedOptions.headers), + resolvedOptions.credentials || 'same-origin', + threadId, + ) + }, } } @@ -1512,6 +1653,19 @@ export function xhrServerSentEvents( resolvedOptions.reconnect, ) }, + async hydrate(threadId) { + const resolvedUrl = typeof url === 'function' ? url() : url + const resolvedOptions = await resolveXhrConnectionOptions(options) + // Hydration is a non-streaming JSON GET, so fetch is fine even for the + // XHR-backed streaming adapter. + return fetchThreadHydration( + fetch, + resolvedUrl, + mergeHeaders(resolvedOptions.headers), + resolvedOptions.withCredentials ? 'include' : 'same-origin', + threadId, + ) + }, } } @@ -1569,6 +1723,19 @@ export function xhrHttpStream( resolvedOptions.reconnect, ) }, + async hydrate(threadId) { + const resolvedUrl = typeof url === 'function' ? url() : url + const resolvedOptions = await resolveXhrConnectionOptions(options) + // Hydration is a non-streaming JSON GET, so fetch is fine even for the + // XHR-backed streaming adapter. + return fetchThreadHydration( + fetch, + resolvedUrl, + mergeHeaders(resolvedOptions.headers), + resolvedOptions.withCredentials ? 'include' : 'same-origin', + threadId, + ) + }, } } diff --git a/packages/ai-client/src/index.ts b/packages/ai-client/src/index.ts index b03ed1480..e8091f85b 100644 --- a/packages/ai-client/src/index.ts +++ b/packages/ai-client/src/index.ts @@ -28,6 +28,9 @@ export type { StructuredOutputPart, // Client configuration types ChatClientPersistence, + ChatPersistedState, + ChatPersistenceOption, + ChatStorageAdapter, ChatClientOptions, ChatPendingInterrupt, BoundInterruptBase, @@ -84,6 +87,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..bc67a358c --- /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 adapter failures as best-effort + * (storage errors never break chat setup or streaming). + */ +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..33c73cb41 100644 --- a/packages/ai-client/src/types.ts +++ b/packages/ai-client/src/types.ts @@ -553,23 +553,84 @@ 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 } +/** + * The `persistence` option for a chat. + * + * - `false` (default): ephemeral. Messages live in memory only; a reload starts + * from empty. + * - `true`: server-authoritative. Nothing is cached in the browser. On mount the + * client hydrates the thread from the server by its `threadId` (paints the + * stored transcript and tails any run still generating), so a reload or the + * same thread opened on another device both just resume. Requires a connection + * with a `hydrate` handler. + * - a {@link ChatClientPersistence} adapter: client-authoritative. The combined + * {@link ChatPersistedState} record (transcript plus resume pointer) is cached + * in the browser and restored on reload with no network. + */ +export type ChatPersistenceOption< + TTools extends ReadonlyArray = any, +> = boolean | ChatClientPersistence + type IsUnknown = unknown extends T ? [T] extends [unknown] ? true @@ -661,21 +722,39 @@ 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. + * How this chat persists across reloads. See {@link ChatPersistenceOption}. + * + * - Omit or `false`: ephemeral, in-memory only. + * - `true`: server-authoritative. The client caches nothing and hydrates the + * thread from the server by its `threadId` on mount (needs a connection with + * a `hydrate` handler). Big transcripts never touch the browser, and the same + * thread opens the same way on another device. + * - a {@link ChatClientPersistence} adapter: client-authoritative. The combined + * {@link ChatPersistedState} record (transcript plus resume pointer) is cached + * in the browser, restoring the transcript, pending interrupts, and an + * in-flight run on reload. + * + * 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. + * + * The framework hooks (`useChat` / `createChat`) do NOT expose `id`: a hook's + * identity is its `threadId`. This lower-level escape hatch exists only for + * direct `ChatClient` construction. */ 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..23719462f --- /dev/null +++ b/packages/ai-client/tests/resume-snapshot.test.ts @@ -0,0 +1,690 @@ +import { describe, expect, it, vi } from 'vitest' +import { INTERRUPT_BINDING_VERSION } from '@tanstack/ai/client' +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() + }) +}) + +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() + }) + + it('omits joinRun when the property is present but not a function', () => { + // Explicit undefined must not produce a wrapper that throws on rejoin. + // Object.assign sidesteps the literal excess-property check to model a JS + // caller passing `joinRun: undefined` against the typed interface. + const normalized = normalizeConnectionAdapter( + Object.assign({ connect: async function* () {} }, { joinRun: undefined }), + ) + 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('persistence:true hydrates history AND tails a live run from the server on mount', async () => { + // Server-authoritative: the client caches no transcript and no run pointer. + // On mount it calls connection.hydrate(threadId), which returns the stored + // transcript plus a cursor to the in-flight run — the client paints the + // history and tails the run via joinRun. No loader, no seeded pointer. + + const joinRun = vi.fn(async function* (_runId: string) { + for (const chunk of runChunks('r1', 't1')) { + yield chunk + } + }) + const connection: ResumableConnectConnectionAdapter = { + connect: async function* () {}, + joinRun, + hydrate: () => + Promise.resolve({ + messages: [createUIMessage('history-1', 'earlier turn', 'user')], + activeRun: { runId: 'r1' }, + interrupts: null, + }), + } + + let latest: Array = [] + const client = new ChatClient({ + threadId: 't1', + connection, + persistence: true, + 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-hydrated history is painted... + expect(latest.some((m) => m.id === 'history-1')).toBe(true) + // ...and the live run was tailed off the durability log, addressed by the + // server-resolved run id (the client only ever supplied the threadId). + expect(joinRun).toHaveBeenCalledWith('r1', expect.anything()) + void client + }) + + it('persistence:true hydrates a transcript with no active run (no tail)', async () => { + const joinRun = vi.fn(async function* () {}) + const connection: ResumableConnectConnectionAdapter = { + connect: async function* () {}, + joinRun, + hydrate: () => + Promise.resolve({ + messages: [ + createUIMessage('u1', 'hi', 'user'), + createUIMessage('a1', 'done', 'assistant'), + ], + activeRun: null, + interrupts: null, + }), + } + let latest: Array = [] + const client = new ChatClient({ + threadId: 't1', + connection, + persistence: true, + onMessagesChange: (messages) => { + latest = messages + }, + }) + await vi.waitFor(() => { + expect(latest.some((m) => m.id === 'a1')).toBe(true) + }) + // No active run → the transcript is painted and nothing is tailed. + expect(joinRun).not.toHaveBeenCalled() + void client + }) + + it('persistence:true restores a pending interrupt from the server on mount', async () => { + // Regression: a run paused on an interrupt is not "running", so there is no + // tail — but a reload must still re-prompt the approval. The client restores + // it from the server hydrate result (not client storage), so a fresh client + // (or another device) shows a resolvable interrupt keyed to the run it paused. + const joinRun = vi.fn(async function* () {}) + const connection: ResumableConnectConnectionAdapter = { + connect: async function* () {}, + joinRun, + hydrate: () => + Promise.resolve({ + messages: [createUIMessage('u1', 'send an email', 'user')], + activeRun: null, + interrupts: { + runId: 'run-paused', + pending: [ + { + id: 'int-1', + reason: 'confirmation', + metadata: { + 'tanstack:interruptBinding': { + v: INTERRUPT_BINDING_VERSION, + kind: 'generic', + interruptId: 'int-1', + interruptedRunId: 'run-paused', + generation: 1, + responseSchemaHash: 'none', + }, + }, + }, + ], + }, + }), + } + const client = new ChatClient({ + threadId: 't1', + connection, + persistence: true, + }) + await vi.waitFor(() => { + expect(client.getInterrupts()).toHaveLength(1) + }) + const [item] = client.getInterrupts() + expect(item?.kind).toBe('generic') + // Restored bound and resolvable, keyed to the run it paused — not tailed. + expect(item?.canResolve).toBe(true) + expect(item?.interruptedRunId).toBe('run-paused') + expect(joinRun).not.toHaveBeenCalled() + }) + + it('restores a pending interrupt even when hydrate also reports an activeRun', async () => { + // A run paused on an interrupt can momentarily still read as `running` on the + // server (the status settles just after the interrupt is persisted), so a + // hydrate that races that window returns BOTH an `activeRun` cursor and the + // pending interrupt. A pending interrupt means the thread is paused, so the + // client must restore the approval and must NOT tail the "active" run (there + // is nothing to stream until the human resolves it). Regression: the earlier + // if/else-if tailed the run and dropped the interrupt, so the approval card + // vanished on reload whenever this race hit. + const joinRun = vi.fn(async function* () {}) + const connection: ResumableConnectConnectionAdapter = { + connect: async function* () {}, + joinRun, + hydrate: () => + Promise.resolve({ + messages: [createUIMessage('u1', 'send an email', 'user')], + activeRun: { runId: 'run-paused' }, + interrupts: { + runId: 'run-paused', + pending: [ + { + id: 'int-1', + reason: 'confirmation', + metadata: { + 'tanstack:interruptBinding': { + v: INTERRUPT_BINDING_VERSION, + kind: 'generic', + interruptId: 'int-1', + interruptedRunId: 'run-paused', + generation: 1, + responseSchemaHash: 'none', + }, + }, + }, + ], + }, + }), + } + const client = new ChatClient({ + threadId: 't1', + connection, + persistence: true, + }) + await vi.waitFor(() => { + expect(client.getInterrupts()).toHaveLength(1) + }) + expect(client.getInterrupts()[0]?.canResolve).toBe(true) + // Paused run is never tailed, even though hydrate reported it active. + expect(joinRun).not.toHaveBeenCalled() + }) + + it('rebuilds a hydrated in-flight partial in place (no duplicate) when tailing on mount', async () => { + // The hydrated transcript includes a PARTIAL assistant reply (a streaming + // snapshot) carrying the same messageId the live run uses. Tailing it on + // mount must rebuild that bubble from the log into ONE clean message — not + // seed+append into "worworld", and not leave a second bubble. + + const joinRun = vi.fn(async function* (_runId: string) { + for (const chunk of runChunks('r1', 't1')) yield chunk + }) + const connection: ResumableConnectConnectionAdapter = { + connect: async function* () {}, + joinRun, + hydrate: () => + Promise.resolve({ + messages: [ + createUIMessage('user-1', 'hi', 'user'), + { + id: 'assistant-1', + role: 'assistant', + parts: [{ type: 'text', content: 'wor' }], + createdAt: new Date(), + }, + ], + activeRun: { runId: 'r1' }, + interrupts: null, + }), + } + + let latest: Array = [] + const client = new ChatClient({ + threadId: 't1', + connection, + persistence: true, + 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 a live run handed to a fresh client via initialResumeSnapshot', async () => { + // A second device/browser opening the thread: no persisted resume pointer of + // its own, but the app's hydration reported an in-flight run id and passed it + // as `initialResumeSnapshot`. The client must tail it, not just restore + // interrupts. Mirrors the server-authoritative page handing over `activeRunId`. + 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, + // History the app fetched from the server (reconstructChat) and seeded. + initialMessages: [createUIMessage('history-1', 'earlier turn', 'user')], + initialResumeSnapshot: { + schemaVersion: 2, + resumeState: { threadId: 't1', runId: 'r1' }, + }, + 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()) + // Seeded history survives alongside the tailed reply. + expect(latest.some((m) => m.id === 'history-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 + }) + + it('clears a dead resume pointer when joinRun never attaches', async () => { + const { adapter, read } = memoryAdapter({ + messages: [createUIMessage('user-1', 'hi', 'user')], + resume: { + schemaVersion: 2, + resumeState: { threadId: 't1', runId: 'gone-run' }, + }, + }) + // joinRun hangs until aborted by the connect deadline — never yields. + const joinRun = vi.fn(async function* ( + _runId: string, + signal?: AbortSignal, + ) { + await new Promise((resolve) => { + if (signal?.aborted) { + resolve() + return + } + signal?.addEventListener('abort', () => resolve(), { once: true }) + }) + }) + const connection: ResumableConnectConnectionAdapter = { + connect: async function* () {}, + joinRun, + } + let status: string | undefined + const client = new ChatClient({ + id: 'chat-dead', + threadId: 't1', + connection, + persistence: adapter, + onStatusChange: (s) => { + status = s + }, + }) + + await vi.waitFor( + () => { + expect(joinRun).toHaveBeenCalled() + expect(status).toBe('ready') + expect(client.getIsLoading()).toBe(false) + }, + { timeout: 5_000 }, + ) + + // Dead pointer must be removed so the next load does not re-pin loading. + await vi.waitFor(() => { + const stored = read() + if (stored && !Array.isArray(stored)) { + expect(stored.resume).toBeUndefined() + } else { + // A bare message array (or a removed key) carries no resume pointer by + // construction — nothing further to assert on the record shape. + expect(stored === undefined || Array.isArray(stored)).toBe(true) + } + }) + void client + }) + + it('with persistence:true a dead server-tail frees the input', async () => { + // Server hydration reports an active run, but its durability log is gone, so + // joinRun never attaches. The client must free the input (isLoading false). + // Being server-authoritative it has no client store to write to at all. + const joinRun = vi.fn(async function* ( + _runId: string, + signal?: AbortSignal, + ) { + await new Promise((resolve) => { + if (signal?.aborted) { + resolve() + return + } + signal?.addEventListener('abort', () => resolve(), { once: true }) + }) + }) + const connection: ResumableConnectConnectionAdapter = { + connect: async function* () {}, + joinRun, + hydrate: () => + Promise.resolve({ + messages: [createUIMessage('history-1', 'seed', 'user')], + activeRun: { runId: 'gone-run' }, + interrupts: null, + }), + } + const client = new ChatClient({ + threadId: 't1', + connection, + persistence: true, + }) + + await vi.waitFor( + () => { + expect(joinRun).toHaveBeenCalled() + expect(client.getIsLoading()).toBe(false) + }, + { timeout: 5_000 }, + ) + void client + }) + + it('surfaces post-attach rejoin errors via onError', async () => { + const { adapter } = memoryAdapter({ + messages: [createUIMessage('user-1', 'hi', 'user')], + resume: { + schemaVersion: 2, + resumeState: { threadId: 't1', runId: 'r1' }, + }, + }) + const joinRun = vi.fn(async function* (_runId: string) { + yield { + type: 'RUN_STARTED', + runId: 'r1', + threadId: 't1', + timestamp: 1, + } as StreamChunk + yield { + type: 'TEXT_MESSAGE_START', + messageId: 'a1', + role: 'assistant', + timestamp: 2, + } as StreamChunk + throw new Error('transport died mid-replay') + }) + const connection: ResumableConnectConnectionAdapter = { + connect: async function* () {}, + joinRun, + } + const onError = vi.fn() + const client = new ChatClient({ + id: 'chat-err', + threadId: 't1', + connection, + persistence: adapter, + onError, + }) + + await vi.waitFor(() => { + expect(onError).toHaveBeenCalled() + }) + expect(String(onError.mock.calls[0]?.[0])).toMatch(/transport died/) + void client + }) +}) diff --git a/packages/ai-mcp/package.json b/packages/ai-mcp/package.json index 860f77701..95af96517 100644 --- a/packages/ai-mcp/package.json +++ b/packages/ai-mcp/package.json @@ -23,7 +23,8 @@ "model-context-protocol", "tanstack", "tools", - "typescript" + "typescript", + "tanstack-intent" ], "type": "module", "module": "./dist/esm/index.js", @@ -47,7 +48,8 @@ }, "files": [ "dist", - "src" + "src", + "skills" ], "scripts": { "build": "vite build && tsup --config tsup.bin.config.ts", diff --git a/packages/ai-memory/package.json b/packages/ai-memory/package.json index c96b8256b..15fcba1e3 100644 --- a/packages/ai-memory/package.json +++ b/packages/ai-memory/package.json @@ -59,7 +59,8 @@ "tanstack", "memory", "redis", - "rag" + "rag", + "tanstack-intent" ], "dependencies": { "@tanstack/ai-event-client": "workspace:*" diff --git a/packages/ai-persistence/package.json b/packages/ai-persistence/package.json new file mode 100644 index 000000000..f86e0563d --- /dev/null +++ b/packages/ai-persistence/package.json @@ -0,0 +1,65 @@ +{ + "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", + "tanstack-intent", + "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", + "skills" + ], + "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/skills/ai-persistence/SKILL.md b/packages/ai-persistence/skills/ai-persistence/SKILL.md new file mode 100644 index 000000000..7463890fe --- /dev/null +++ b/packages/ai-persistence/skills/ai-persistence/SKILL.md @@ -0,0 +1,177 @@ +--- +name: ai-persistence +description: > + Durability and state persistence for TanStack AI chats with + @tanstack/ai-persistence. Routes to server chat persistence (withPersistence), + client persistence (localStorage/IndexedDB), the store contracts, and adapter + recipes. Distinguishes delivery durability (resumable streams) from + conversation state. Use when conversations must survive reloads, multi-device, + approvals, or server restarts — NOT for stream reconnect alone. +type: core +library: tanstack-ai +library_version: '0.0.0' +sources: + - 'TanStack/ai:docs/persistence/overview.md' + - 'TanStack/ai:docs/persistence/chat-persistence.md' + - 'TanStack/ai:docs/persistence/client-persistence.md' + - 'TanStack/ai:docs/persistence/controls.md' + - 'TanStack/ai:docs/persistence/build-your-own-adapter.md' +--- + +# TanStack AI Persistence + +> Builds on the `ai-core` skill in `@tanstack/ai`, and usually +> `ai-core/chat-experience`. + +TanStack AI splits **delivery durability** from **state persistence**. They +share no code and solve different problems. + +| Layer | Answers | Package / API | +| ----------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------- | +| **Delivery durability** | Reconnect to a stream still running | `memoryStream` / `@tanstack/ai-durable-stream` on the response; see resumable streams docs | +| **State persistence** | What is the conversation, later? | Client `persistence` on `useChat` + server `withPersistence` from `@tanstack/ai-persistence` | + +A replayable stream is **not** a saved conversation. A saved conversation is +**not** a live stream. Production apps often use both. + +## Persistence is a contract, not a database + +`@tanstack/ai-persistence` ships the **store interfaces**, the middleware that +drives them, an in-memory reference backend, and a conformance testkit. It does +**not** ship a backend for your database, and you do not need one: implement the +stores against whatever you already run — Postgres, SQLite, D1, Mongo — and hand +the result to `withPersistence`. The core never inspects your tables. + +| Ships in the package | What it is | +| --------------------------------------------------------------------------- | ------------------------------------------------------ | +| `MessageStore` / `RunStore` / `InterruptStore` / `MetadataStore` | The four state contracts | +| `withPersistence` / `withGenerationPersistence` | Chat + generation middleware | +| `memoryPersistence()` | In-process reference backend (dev, tests) | +| `reconstructChat` | Server hydrate route helper | +| `LockStore` / `withLocks` / `InMemoryLockStore` (from `@tanstack/ai/locks`) | Coordination, **not** this package — see ai-core/locks | +| `@tanstack/ai-persistence/testkit` | `runPersistenceConformance` compatibility gate | + +## Sub-skills + +| Need to... | Read | +| ----------------------------------------------- | ----------------------------------------------------- | +| Wire server-side chat history, runs, interrupts | ai-persistence/server/SKILL.md | +| Survive reloads in the browser | ai-core/client-persistence/SKILL.md in `@tanstack/ai` | +| Implement the store interfaces for your DB | ai-persistence/stores/SKILL.md | +| Multi-instance locks (separate from state) | ai-core/locks/SKILL.md in `@tanstack/ai` | + +Adding persistence to an app? Pick the recipe that matches what it already +runs — each one writes a single `chat-persistence.ts` against the app's +existing database client and schema: + +| The app runs... | Read | +| ------------------------------------------------ | ------------------------------------------------ | +| Drizzle ORM (SQLite / Postgres / MySQL) | ai-persistence/build-drizzle-adapter/SKILL.md | +| Prisma | ai-persistence/build-prisma-adapter/SKILL.md | +| Cloudflare Workers + D1 (± Durable Object locks) | ai-persistence/build-cloudflare-adapter/SKILL.md | +| Anything else — raw `pg`, Kysely, SQLite, Mongo | ai-persistence/build-custom-adapter/SKILL.md | + +## State persistence has two halves + +| Half | Stores | Survives | Typical use | +| ---------- | ----------------------------------------------- | -------------------------------- | ---------------------------------------- | +| **Client** | transcript ± resume pointer in browser storage | reload / tab close (per browser) | SPA restore, offline-first | +| **Server** | messages, runs, interrupts, metadata in your DB | restart + multi-device | authoritative history, durable approvals | + +They are independent. Use either alone or both. + +## Identity: `threadId` and `Scope` + +Server stores key on **`threadId`** (same as `chat({ threadId })` / +`ChatMiddlewareContext.threadId` / `Scope.threadId` from `@tanstack/ai`). + +- Store methods take bare `threadId` strings for adapter simplicity. +- Multi-user isolation is **your** job: derive `userId` / `tenantId` from + session server-side; authorize before load/save / `reconstructChat`. +- Never treat a client-supplied thread id alone as ownership — ids are guessable. + +## Authoritative-history contract + +When both halves run, ownership per turn is decided by request `messages`: + +| Client sends | Meaning | On finish | +| ------------------------ | --------------------------------- | ----------------------------------- | +| **Non-empty** `messages` | Full transcript (source of truth) | Server **overwrites** stored thread | +| **Empty** `messages` | Continue from server copy | Server **loads** stored thread | + +Never post a delta as `messages` — that wipes history down to the delta. + +**Client-authoritative:** always send full transcript; browser is truth, server mirrors. +**Server-authoritative:** send empty `messages` (or hydrate via server load); server is truth, multi-device works. + +## Recommended production stack + +1. **Client:** `persistence: true` — server-authoritative, no client cache. +2. **Server:** `withPersistence(backend)` — messages + runs + interrupts. +3. **Route:** delivery durability if mid-stream reconnect matters. +4. **Optional:** `withLocks(distributedLockStore)` from `@tanstack/ai/locks` when other middleware needs multi-instance coordination (not part of the state bag). + +## Minimal end-to-end sketch + +**Server** + +```ts +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { withPersistence } from '@tanstack/ai-persistence' +// Your adapter — see ai-persistence/stores. +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) +} +``` + +**Client (server-authoritative)** + +```tsx +import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' + +function Chat({ threadId }: { threadId: string }) { + const { messages, sendMessage } = useChat({ + threadId, + connection: fetchServerSentEvents('/api/chat'), + persistence: true, + }) + // ... +} +``` + +With `persistence: true`, the client caches nothing and hydrates the transcript +from the server on mount (thread id is the key). Pair with a server load path +such as `reconstructChat` for the GET. + +## Critical rules + +1. **Not Vercel AI SDK.** Persistence is `@tanstack/ai-persistence` + middleware, not Vercel `useChat` storage hacks. +2. **`saveThread` is full overwrite**, never append. +3. **`createOrResume` is insert-if-absent** for the same `runId`. +4. **Interrupt `create` is insert-if-absent** — never clobber resolved → pending. +5. **Locks ≠ state.** Import `withLocks` from `@tanstack/ai/locks`. Sandbox resume is a sandbox-package concern — not a `stores` key. `stores` accepts only `messages`, `runs`, `interrupts`, `metadata`. +6. **You own the schema.** No package invents migrations for you. +7. **Run the conformance testkit** against any adapter you write. +8. **Authorize thread access** at the route boundary. + +## Cross-references + +- **ai-core/chat-experience** (`@tanstack/ai`) — `useChat`, SSE, client `persistence` option overview +- **ai-core/middleware** (`@tanstack/ai`) — middleware hooks; `withPersistence` is a ChatMiddleware +- **Resumable streams docs** — delivery durability only diff --git a/packages/ai-persistence/skills/ai-persistence/build-cloudflare-adapter/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-cloudflare-adapter/SKILL.md new file mode 100644 index 000000000..d91f569ef --- /dev/null +++ b/packages/ai-persistence/skills/ai-persistence/build-cloudflare-adapter/SKILL.md @@ -0,0 +1,260 @@ +--- +name: ai-persistence/build-cloudflare-adapter +description: Use when a Cloudflare Worker needs TanStack AI chat persistence — writes a chat-persistence.ts into the app against its D1 binding (raw or via Drizzle), plus a Durable Object LockStore. Covers per-request bindings, wrangler config, D1 migrations, and lease-based locks. +--- + +# Cloudflare Chat Persistence + +The deliverable is **one file in the Worker** — `src/lib/chat-persistence.ts` — +exporting a factory that builds a `ChatPersistence` from the request's D1 +binding, plus (when the app needs coordination) a Durable Object lock store. +Tables go into the app's existing `migrations/` directory and are applied with +`wrangler d1 migrations apply`. + +Do not create a package or a migration runner. Wrangler already tracks applied +migrations; a second bookkeeping table only creates drift. + +Read the **Build Your Own Adapter** guide +(`docs/persistence/build-your-own-adapter.md`) for the store contracts, and +**ai-persistence/stores** for the shape rules. This skill covers only +the Cloudflare-specific parts. + +## 1. Read the app before writing anything + +| Find | Where to look | What it decides | +| ---------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------- | +| D1 binding name | `wrangler.jsonc` `d1_databases[].binding` | `env.DB` vs `env.AI_STATE` in the factory | +| How `env` reaches code | the Worker `fetch(request, env)`, or an async-local helper (`getDb()`, `getCloudflareContext()`) | Whether the factory takes `env` or reads a helper | +| Drizzle or raw D1 | `drizzle-orm` in `package.json`, a `src/db/schema.ts` | Which recipe below to follow | +| Migrations dir | `wrangler.jsonc` `migrations_dir`, default `migrations/` | Where the new `.sql` file goes | +| Existing table names | the current migrations / schema | Prefix (`chat_*`) so nothing collides | + +## 2. Two independent pieces + +``` +D1 database -> messages, runs, interrupts, metadata (AIPersistence.stores) +Durable Object -> LockStore (withLocks — NOT a store) +``` + +These do not compose into one object. `AIPersistence.stores` accepts exactly +four keys; putting `locks` in the map — or in a `composePersistence` override — +throws `Unknown AIPersistence store key: locks` and fails to type-check. Return +the state persistence from one factory and the lock store from another, then +wire them as two middlewares. + +Most apps need only the first piece. Add the Durable Object when other +middleware genuinely needs mutual exclusion across isolates — +`InMemoryLockStore` gives none, because a Worker runs on many isolates at once. + +## 3. Bindings are per-request + +This is the one rule that separates Cloudflare from every other backend. A D1 +binding does not exist at module scope, so `chat-persistence.ts` **must export a +factory**, not a const: + +```ts ignore +import { defineAIPersistence } from '@tanstack/ai-persistence' +import type { ChatPersistence } from '@tanstack/ai-persistence' + +/** Call inside a request handler — `env` is not available at module scope. */ +export function chatPersistence(d1: D1Database): ChatPersistence { + return defineAIPersistence({ + stores: { + messages: createMessageStore(d1), + runs: createRunStore(d1), + interrupts: createInterruptStore(d1), + metadata: createMetadataStore(d1), + }, + }) +} +``` + +Annotate `ChatPersistence` — bare `AIPersistence` is the all-optional bag and +`withPersistence` rejects it. Building it per request is cheap: the stores hold +no state beyond the binding. + +## 4. The stores + +Two routes, same invariants: + +- **Drizzle over D1** — if the app already runs Drizzle, wrap the binding with + `drizzle(env.DB, { schema })` and follow + **ai-persistence/build-drizzle-adapter** verbatim (its "if `db` is + per-request" section is exactly this case). Stop reading here. +- **Raw D1** — implement the four stores against `d1.prepare(sql).bind(...)`: + `.first()` for `get`, `.all()` for `list*`, `.run()` for writes. D1 speaks + SQLite, so this mirrors the `node:sqlite` walkthrough in the guide one-for-one; + everything is already async, so no `Promise.resolve` wrapping. + +The invariants are the whole game, whichever route you take: + +| Store | Rule | +| ------------ | ----------------------------------------------------------------------------------------------------------------- | +| `messages` | `saveThread` is a full replace (`INSERT … ON CONFLICT(thread_id) DO UPDATE`) | +| `runs` | `createOrResume` reads first, else `INSERT … ON CONFLICT DO NOTHING`, then re-reads | +| `runs` | `update` on an unknown id is a silent no-op — never throws, never inserts | +| `runs` | `findActiveRun` returns the latest `'running'` run for the thread, else null — required for reload/switch tailing | +| `interrupts` | `create` is insert-if-absent; never clobber a resolved interrupt back to pending | +| `interrupts` | every `list*` ends `ORDER BY requested_at ASC` | +| `metadata` | reject nullish `set` with a clear `TypeError`; tell callers to use `delete` | + +Row mappers omit absent optionals (`...(row.error != null ? { error: row.error } : {})`) +so records compare cleanly against the reference in-memory backend. JSON columns +are `text` — `JSON.parse` on read, `JSON.stringify` on write. Timestamps are +`integer` epoch ms. + +## 5. The migration + +Write the tables into the app's `migrations/` directory as a new numbered file: + +```sql +CREATE TABLE IF NOT EXISTS chat_threads ( + thread_id text PRIMARY KEY NOT NULL, + messages_json text NOT NULL, + updated_at integer NOT NULL +); +CREATE TABLE IF NOT EXISTS chat_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 +); +CREATE INDEX IF NOT EXISTS chat_runs_thread_status ON chat_runs (thread_id, status); +CREATE TABLE IF NOT EXISTS chat_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 +); +CREATE INDEX IF NOT EXISTS chat_interrupts_thread ON chat_interrupts (thread_id, requested_at); +CREATE TABLE IF NOT EXISTS chat_metadata ( + namespace text NOT NULL, + key text NOT NULL, + value_json text NOT NULL, + PRIMARY KEY (namespace, key) +); +``` + +Apply with `wrangler d1 migrations apply ` (`--local` first, then +`--remote`). If the app also uses Drizzle, generate this file with +`drizzle-kit generate` instead of hand-writing it — the SQL and the Drizzle +table definitions must agree, so let one of them own the other. + +## 6. Wire it into the chat route + +```ts ignore +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { withPersistence } from '@tanstack/ai-persistence' +import { chatPersistence } from './lib/chat-persistence' + +export default { + async fetch(request: Request, env: Env) { + 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(chatPersistence(env.DB))], + }) + return toServerSentEventsResponse(stream) + }, +} +``` + +`threadId` is a bare string to the stores. **Authorize thread access at the +route** — derive the user from the session, never trust a client-supplied id. + +## 7. Durable Object lock store (only if needed) + +Implement `LockStore` from `@tanstack/ai/locks`. `withLock(key, fn)` +routes each key to a Durable Object instance (`idFromName(key)`) that serializes +owners. Use **leases** so a crashed owner cannot block forever: the DO grants a +lease with an expiry, an alarm reclaims it, and the lock passes the callback an +`AbortSignal` that fires when ownership can no longer be guaranteed. Callbacks +must stop starting external mutations once the signal aborts. + +Export the DO class from the Worker entry so wrangler can bind it: + +```ts ignore +export { ChatLockDurableObject } from './locks' +``` + +Then wire both middlewares: + +```ts ignore +import { withLocks } from '@tanstack/ai/locks' +import { withPersistence } from '@tanstack/ai-persistence' + +const middleware = [ + withPersistence(chatPersistence(env.AI_STATE)), + withLocks(createDurableObjectLockStore(env.AI_LOCKS)), +] +``` + +## wrangler bindings + +```jsonc +{ + "d1_databases": [ + { + "binding": "AI_STATE", + "database_name": "tanstack-ai-state", + "database_id": "", + "migrations_dir": "migrations", + }, + ], + "durable_objects": { + "bindings": [{ "name": "AI_LOCKS", "class_name": "ChatLockDurableObject" }], + }, + "migrations": [ + { "tag": "v1", "new_sqlite_classes": ["ChatLockDurableObject"] }, + ], +} +``` + +Durable Object locks do not use the D1 table migration set; their state is +configured through the migration tags above. + +## Verify + +```ts ignore +import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' +import { env } from 'cloudflare:test' +import { chatPersistence } from '../src/lib/chat-persistence' + +runPersistenceConformance('app-d1', () => chatPersistence(env.AI_STATE)) +``` + +Run it against a Miniflare D1 binding with the migration applied, reset between +runs. All four state stores are provided, so pass no `skip` — and `skip` never +accepts `'locks'`: the suite covers state only. + +The lock store needs its **own** tests, because nothing in the conformance suite +touches it. Cover at minimum: two concurrent `withLock` calls on the same key +serialize; different keys do not block each other; a lease that expires aborts +the signal handed to the critical section; and a callback that throws still +releases the lock. + +## Only if you are publishing this as a package + +For a reusable npm adapter rather than a file in the app: peer-dep +`@cloudflare/workers-types >=4.x`, and prepend +`/// ` to the generated +`index.d.ts` so consumers get the D1/DurableObject types. Emit the table SQL +into the consumer's `migrations/` directory rather than shipping a runner, and +if you offer both raw-D1 and Drizzle paths, guard with a test that the emitted +SQL and the Drizzle tables describe the same schema. diff --git a/packages/ai-persistence/skills/ai-persistence/build-custom-adapter/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-custom-adapter/SKILL.md new file mode 100644 index 000000000..74e17bd13 --- /dev/null +++ b/packages/ai-persistence/skills/ai-persistence/build-custom-adapter/SKILL.md @@ -0,0 +1,276 @@ +--- +name: ai-persistence/build-custom-adapter +description: Use when an app needs TanStack AI chat persistence on a database with no dedicated recipe — raw Postgres (pg/postgres.js), Kysely, node:sqlite, MongoDB, Supabase, Redis. Writes a chat-persistence.ts against the app's existing client, covering the four stores, the idempotency invariants, and the conformance gate. Route to the Drizzle, Prisma, or Cloudflare skills instead when one of those matches. +--- + +# Custom Chat Persistence + +The deliverable is **one file in the app** — `src/lib/chat-persistence.ts` — +exporting a `ChatPersistence` built from the database client the app already +has. Plus whatever DDL that database needs, added through the app's existing +migration flow. + +Do not create a package, a second client, or a migration runner. + +**Route first.** If the app already runs one of these, stop and use that skill — +it has the driver-specific code: + +| App runs | Use | +| ------------------------- | --------------------------------------- | +| Drizzle ORM (any dialect) | ai-persistence/build-drizzle-adapter | +| Prisma | ai-persistence/build-prisma-adapter | +| Cloudflare Workers + D1 | ai-persistence/build-cloudflare-adapter | + +Everything else lands here. The full contracts and their invariants are in +**ai-persistence/stores**; the complete worked `node:sqlite` walkthrough +is `docs/persistence/build-your-own-adapter.md` and +`examples/ts-react-chat/src/lib/sqlite-persistence.ts`. + +## 1. Read the app before writing anything + +| Find | Where to look | What it decides | +| ------------------ | ------------------------------------------------------------- | ------------------------------------------------------ | +| The client | `src/db.ts`, `src/lib/db.ts`, `src/server/db.ts` | What the file imports — never construct a second pool | +| Client lifetime | module singleton vs per-request factory (`getDb()`, bindings) | `export const chatPersistence` vs `export function` | +| Migration flow | `migrations/`, `drizzle/`, `supabase/migrations/`, an ORM CLI | How the DDL gets applied — use theirs, add nothing new | +| Naming conventions | existing tables/collections | Prefix (`chat_*`) so nothing collides | +| JSON support | `jsonb` (Postgres), `json` (MySQL 5.7+), text (SQLite) | Whether mappers stringify/parse | +| Import alias | `tsconfig.json` `paths` | `@/db`, `~/db`, `#/db`, or a relative path | + +## 2. Shape the storage + +Four logical records. Whatever the engine, keep these keys — the store methods +look records up by exactly these: + +| Record | Key | Fields | +| --------- | ------------------ | ----------------------------------------------------------------------------------- | +| thread | `threadId` | `messages` (array, full transcript) | +| run | `runId` | `threadId`, `status`, `startedAt`, `finishedAt?`, `error?`, `usage?` | +| interrupt | `interruptId` | `runId`, `threadId`, `status`, `requestedAt`, `resolvedAt?`, `payload`, `response?` | +| metadata | `(namespace, key)` | `value` | + +- Timestamps are **epoch milliseconds** (`number`) in records. Store them + however the engine prefers and convert in the mapper. +- `(namespace, key)` is a **composite** key. Never join with a separator — + `('a:b','c')` and `('a','b:c')` must stay distinct records, and the + conformance suite checks it. +- Index `runs(threadId, status)` and `interrupts(threadId, requestedAt)` — those + are the two listing paths. +- Extra app-owned columns are fine (a `userId`, audit columns) as long as they + are nullable or defaulted. The stores never read columns they do not know + about. + +## 3. The five invariants + +Getting one of these wrong is the usual source of stuck approvals and wiped +history. They are engine-independent: + +1. **`saveThread` is a full overwrite**, never an append. The argument is the + complete authoritative transcript. +2. **`loadThread` returns `[]`** for an unknown thread, never `null`. +3. **`createOrResume` is insert-if-absent** — an existing `runId` comes back + _unchanged_, ignoring the new field values. Resume and double-submit depend + on it. After a racy insert, re-read rather than trusting your own write. +4. **`runs.update` on an unknown id is a silent no-op** — it must not throw and + must not insert. (Drivers that throw on zero rows affected need the + `updateMany`-style call, not the `update`-one-or-throw call.) +5. **`interrupts.create` is insert-if-absent** — never clobber a resolved + interrupt back to pending. Every `list*` is ordered by `requestedAt` + ascending. + +Row mappers omit absent optionals +(`...(row.error != null ? { error: row.error } : {})`) so records compare +cleanly against the reference in-memory backend. + +## 4. Write `src/lib/chat-persistence.ts` + +Four factories and one assembly. Postgres via `pg` shown here; the shape is the +same for any driver. + +```ts ignore +import { defineAIPersistence } from '@tanstack/ai-persistence' +import type { Pool } from 'pg' +import type { + ChatPersistence, + MessageStore, + RunStore, +} from '@tanstack/ai-persistence' + +import { pool } from '@/db' + +function createMessageStore(db: Pool): MessageStore { + return { + async loadThread(threadId) { + const { rows } = await db.query( + 'SELECT messages_json FROM chat_threads WHERE thread_id = $1', + [threadId], + ) + return rows[0]?.messages_json ?? [] + }, + // Full overwrite — `messages` is the complete authoritative transcript. + async saveThread(threadId, messages) { + await db.query( + `INSERT INTO chat_threads (thread_id, messages_json, updated_at) + VALUES ($1, $2, $3) + ON CONFLICT (thread_id) + DO UPDATE SET messages_json = EXCLUDED.messages_json, + updated_at = EXCLUDED.updated_at`, + [threadId, JSON.stringify(messages), Date.now()], + ) + }, + } +} + +function createRunStore(db: Pool): RunStore { + async function get(runId: string) { + const { rows } = await db.query( + 'SELECT * FROM chat_runs WHERE run_id = $1', + [runId], + ) + return rows[0] ? mapRun(rows[0]) : null + } + + return { + get, + // Idempotent: an existing runId is returned untouched. + async createOrResume({ runId, threadId, startedAt, status }) { + const existing = await get(runId) + if (existing) return existing + + await db.query( + `INSERT INTO chat_runs (run_id, thread_id, status, started_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (run_id) DO NOTHING`, + [runId, threadId, status ?? 'running', startedAt], + ) + // Re-read: a concurrent createOrResume may have won the race, and that + // row is the authoritative one. + const stored = await get(runId) + return ( + stored ?? { runId, threadId, status: status ?? 'running', startedAt } + ) + }, + // ... update (no-op on unknown id), findActiveRun (latest 'running') + } +} + +/** The four chat state stores backed by the app's database. */ +export const chatPersistence: ChatPersistence = defineAIPersistence({ + stores: { + messages: createMessageStore(pool), + runs: createRunStore(pool), + interrupts: createInterruptStore(pool), + metadata: createMetadataStore(pool), + }, +}) +``` + +Annotate `ChatPersistence` — bare `AIPersistence` is the all-optional bag and +`withPersistence` rejects it. There is no `locks` store: `stores` accepts only +`messages`, `runs`, `interrupts`, `metadata`, and anything else throws +`Unknown AIPersistence store key`. Coordination is wired separately with +`withLocks` (see **ai-core/locks**). + +If the client is per-request (Workers bindings, request-scoped transactions), +export a `chatPersistence()` factory instead of a const and call it inside the +handler. + +## Engine notes + +**Postgres (`pg`, `postgres.js`, Neon, Supabase)** — `jsonb` columns round-trip +objects, so skip the `JSON.stringify` on read paths (`pg` parses `jsonb` for +you; check what the driver returns before assuming). `bigint` columns come back +as strings in `pg` — use `bigint` with an explicit `Number()` in the mapper, or +store epoch ms in a `double precision`/`bigint` and convert once. Composite key +is `PRIMARY KEY (namespace, key)`. + +**Kysely** — define the four tables in the app's `Database` interface, then the +stores are `db.insertInto('chat_runs').values(...).onConflict((oc) => oc.column('run_id').doNothing())` +and `.executeTakeFirst()`. `updateTable(...).execute()` is already a no-op on +zero matches, so invariant 4 comes free. + +**node:sqlite / better-sqlite3** — the complete implementation is in the guide +and in `examples/ts-react-chat/src/lib/sqlite-persistence.ts`. Prepared +statements at factory scope, `INSERT ... ON CONFLICT`, JSON as `text`, epoch ms +as `integer`. Wrap sync calls in `async` methods; the contracts are promise-based. + +**MongoDB** — one collection per record type, `_id` set to the natural key +(`threadId`, `runId`, `interruptId`, and `` `${namespace}�${key}` `` or a +compound unique index on `{ namespace, key }` — never a `:`-joined string). +`createOrResume` is `updateOne({ _id }, { $setOnInsert: doc }, { upsert: true })` +then a `findOne` — `$setOnInsert` is the insert-if-absent primitive. Guard the +`E11000` duplicate-key race and re-read. `list*` need `.sort({ requestedAt: 1 })`. + +**Redis / Upstash** — workable for `metadata` and excellent for `LockStore`, but +think before putting `interrupts` there: the listings need ordered secondary +indexes you have to maintain by hand (a sorted set per thread and per run, +scored by `requestedAt`). A common split is Postgres for `messages`/`runs`/ +`interrupts` and Redis for locks; compose them with `composePersistence`. + +**Anything else** — you only need the five invariants above. The core never +inspects your storage. + +## Adopt part of it + +You rarely need all four stores at once. Implement what you own and fill the +rest from another base: + +```ts ignore +import { composePersistence, memoryPersistence } from '@tanstack/ai-persistence' +import { messages, runs } from './my-stores' + +export const chatPersistence = composePersistence(memoryPersistence(), { + overrides: { messages, runs }, +}) +``` + +Only listed keys move. There is **no cross-store transaction** — if `messages` +lives in Postgres and `interrupts` in Redis, a write touching both is two +writes. The idempotency invariants are exactly what make those retries safe. + +## Wire it into the chat route + +```ts ignore +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { withPersistence } from '@tanstack/ai-persistence' +import { chatPersistence } from '@/lib/chat-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(chatPersistence)], + }) + return toServerSentEventsResponse(stream) +} +``` + +`threadId` is a bare string to the stores. **Authorize thread access at the +route** — derive the user from the session, never trust a client-supplied id. + +## Verify (required) + +This matters more here than anywhere else: there is no reference driver to +compare against, so the testkit is the only thing standing between a subtle +idempotency bug and stuck approvals in production. + +```ts ignore +import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' +import { chatPersistence } from '../src/lib/chat-persistence' + +runPersistenceConformance('app-custom', () => chatPersistence) +``` + +Point it at a throwaway database and reset between runs. Declare intentional +omissions with `skip: ['metadata']` — it accepts only +`'messages' | 'runs' | 'interrupts' | 'metadata'`, never `'locks'`, which is not +a state store. diff --git a/packages/ai-persistence/skills/ai-persistence/build-drizzle-adapter/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-drizzle-adapter/SKILL.md new file mode 100644 index 000000000..8f5382984 --- /dev/null +++ b/packages/ai-persistence/skills/ai-persistence/build-drizzle-adapter/SKILL.md @@ -0,0 +1,466 @@ +--- +name: ai-persistence/build-drizzle-adapter +description: Use when an app already runs Drizzle ORM and needs TanStack AI chat persistence — writes a chat-persistence.ts into the app against its existing db handle, schema file, and drizzle-kit journal. Covers the four tables (SQLite/Postgres/MySQL), the onConflict idempotency rules, JSON columns, and per-request bindings like D1. +--- + +# Drizzle Chat Persistence + +The deliverable is **one file in the app** — `src/lib/chat-persistence.ts` — +exporting a `ChatPersistence` built from the app's existing Drizzle `db`. Plus +four tables added to the app's existing schema file and a migration generated +through the app's existing `drizzle-kit` setup. + +Do not create a package, a second `db` instance, a migration runner, or a +`drizzle.config.ts`. The app has those. + +Read the **Build Your Own Adapter** guide +(`docs/persistence/build-your-own-adapter.md`) for the store contracts and +invariants, and **ai-persistence/stores** for the shape rules. Every +store below mirrors the reference in-memory backend in +`@tanstack/ai-persistence` (`memory.ts`); the shared conformance testkit is the +proof. + +## 1. Read the app before writing anything + +| Find | Where to look | What it decides | +| ------------------ | ------------------------------------------------------------------- | ----------------------------------------------------------- | +| Dialect | `drizzle.config.ts` `dialect:`, or the `drizzle-orm/*-core` import | `sqlite-core` vs `pg-core` vs `mysql-core` column builders | +| Schema file(s) | `drizzle.config.ts` `schema:` glob | Where the four tables go — append, never start a new file | +| The `db` handle | `src/db/index.ts`, `src/db.ts`, `src/server/db.ts` | Module singleton (`export const db`) vs factory (`getDb()`) | +| Migration flow | `drizzle.config.ts` `out:`, the `migrations/` or `drizzle/` journal | Which generate/apply commands to tell the user to run | +| Naming conventions | Existing tables in the schema file | Table prefix, var casing, `snake_case` column names | +| Import alias | `tsconfig.json` `paths` | `@/db`, `~/db`, `#/db/index`, or a relative path | + +Match what is already there. If their tables are `chat_*`-prefixed and their +vars are camelCase, so are yours. If they already have a `messages` table for +something else, prefix — the store code reads database names off the table +objects, so any name works. + +**Never invent a migration path.** Add the tables to their schema file, then +have them run their own commands (`npx drizzle-kit generate` then +`migrate`/`push`, or `wrangler d1 migrations apply` for D1). A parallel +migration table behind their back is how schemas drift. + +## 2. Add the tables to their schema file + +SQLite. JSON payloads use `text({ mode: 'json' })` so Drizzle round-trips +objects for you; timestamps are `integer` epoch ms. + +```ts ignore +import { integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import type { ModelMessage, TokenUsage } from '@tanstack/ai' +import type { InterruptRecord, RunStatus } from '@tanstack/ai-persistence' + +export const chatThreads = sqliteTable('chat_threads', { + threadId: text('thread_id').primaryKey(), + messagesJson: text('messages_json', { mode: 'json' }) + .$type>() + .notNull(), + updatedAt: integer('updated_at').notNull(), +}) + +export const chatRuns = sqliteTable('chat_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(), +}) + +export const chatInterrupts = sqliteTable('chat_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(), +}) + +export const chatMetadata = sqliteTable( + 'chat_metadata', + { + namespace: text('namespace').notNull(), + key: text('key').notNull(), + valueJson: text('value_json', { mode: 'json' }).$type().notNull(), + }, + (table) => [primaryKey({ columns: [table.namespace, table.key] })], +) +``` + +`updatedAt` on threads is an app-owned extra, not part of any contract — the +stores never read columns they do not know about, so add `userId`, tenant ids, +or audit columns the same way (nullable or defaulted so inserts still succeed). +The `namespace` column is the `MetadataStore` first argument; the stock SQL in +the guide calls the same column `scope`. + +**Postgres** (`drizzle-orm/pg-core`): `jsonb()` for the JSON payloads, +`bigint({ mode: 'number' })` for epoch-ms timestamps, `text()` elsewhere, +composite `primaryKey` on `(namespace, key)` unchanged. **MySQL** +(`drizzle-orm/mysql-core`): `json()`, `bigint({ mode: 'number' })`, and +`varchar(…, { length: 255 })` for the primary-key columns. The store bodies +below are identical across all three — only `onConflictDoUpdate` becomes +`onDuplicateKeyUpdate` on MySQL. + +## 3. Write `src/lib/chat-persistence.ts` + +The whole file. Idempotency is the entire game — the comments below mark the +rules the conformance suite checks. + +```ts ignore +import { and, asc, desc, eq } from 'drizzle-orm' +import { defineAIPersistence } from '@tanstack/ai-persistence' +import type { SQL } from 'drizzle-orm' +import type { + ChatPersistence, + InterruptRecord, + InterruptStore, + MessageStore, + MetadataStore, + RunRecord, + RunStore, +} from '@tanstack/ai-persistence' + +import { db } from '@/db' +import { + chatInterrupts, + chatMetadata, + chatRuns, + chatThreads, +} from '@/db/schema' + +type Db = typeof db + +// Records omit absent optionals so they compare cleanly against the reference +// in-memory backend. +function mapRun(row: typeof chatRuns.$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 } : {}), + } +} + +function mapInterrupt( + row: typeof chatInterrupts.$inferSelect, +): InterruptRecord { + return { + interruptId: row.interruptId, + runId: row.runId, + threadId: row.threadId, + status: row.status, + requestedAt: row.requestedAt, + payload: row.payloadJson, + ...(row.resolvedAt != null ? { resolvedAt: row.resolvedAt } : {}), + ...(row.responseJson != null ? { response: row.responseJson } : {}), + } +} + +function createMessageStore(db: Db): MessageStore { + return { + async loadThread(threadId) { + const rows = await db + .select({ messagesJson: chatThreads.messagesJson }) + .from(chatThreads) + .where(eq(chatThreads.threadId, threadId)) + .limit(1) + // Unknown thread is [], never null. + return rows[0]?.messagesJson ?? [] + }, + // Full overwrite — `messages` is the complete authoritative transcript. + async saveThread(threadId, messages) { + const updatedAt = Date.now() + await db + .insert(chatThreads) + .values({ threadId, messagesJson: messages, updatedAt }) + .onConflictDoUpdate({ + target: chatThreads.threadId, + set: { messagesJson: messages, updatedAt }, + }) + }, + } +} + +function createRunStore(db: Db): RunStore { + async function get(runId: string) { + const rows = await db + .select() + .from(chatRuns) + .where(eq(chatRuns.runId, runId)) + .limit(1) + return rows[0] ? mapRun(rows[0]) : null + } + + return { + get, + // Idempotent: an existing runId is returned untouched so resume and + // double-submit are safe. + async createOrResume({ runId, threadId, startedAt, status }) { + const existing = await get(runId) + if (existing) return existing + + await db + .insert(chatRuns) + .values({ runId, threadId, status: status ?? 'running', startedAt }) + .onConflictDoNothing({ target: chatRuns.runId }) + + // Re-read rather than trusting the insert: a concurrent createOrResume + // may have won the race, and that row is the authoritative one. + const stored = await get(runId) + return ( + stored ?? { runId, threadId, status: status ?? 'running', startedAt } + ) + }, + // Patching an unknown runId is a no-op: never throws, never inserts. + 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(chatRuns).set(set).where(eq(chatRuns.runId, runId)) + }, + // Optional in the contract; enables reconnect without a client-held run id. + async findActiveRun(threadId) { + const rows = await db + .select() + .from(chatRuns) + .where( + and(eq(chatRuns.threadId, threadId), eq(chatRuns.status, 'running')), + ) + .orderBy(desc(chatRuns.startedAt)) + .limit(1) + return rows[0] ? mapRun(rows[0]) : null + }, + } +} + +function createInterruptStore(db: Db): InterruptStore { + // Every listing is ordered by requestedAt ascending. + const listWhere = async (where: SQL | undefined) => { + const rows = await db + .select() + .from(chatInterrupts) + .where(where) + .orderBy(asc(chatInterrupts.requestedAt)) + return rows.map(mapInterrupt) + } + + return { + // Insert-if-absent: a duplicate create must never clobber a resolved + // interrupt back to pending. + async create(record) { + await db + .insert(chatInterrupts) + .values({ + interruptId: record.interruptId, + runId: record.runId, + threadId: record.threadId, + status: 'pending', + requestedAt: record.requestedAt, + payloadJson: record.payload, + ...(record.response !== undefined + ? { responseJson: record.response } + : {}), + }) + .onConflictDoNothing({ target: chatInterrupts.interruptId }) + }, + async resolve(interruptId, response) { + await db + .update(chatInterrupts) + .set({ + status: 'resolved', + resolvedAt: Date.now(), + ...(response !== undefined ? { responseJson: response } : {}), + }) + .where(eq(chatInterrupts.interruptId, interruptId)) + }, + async cancel(interruptId) { + await db + .update(chatInterrupts) + .set({ status: 'cancelled', resolvedAt: Date.now() }) + .where(eq(chatInterrupts.interruptId, interruptId)) + }, + async get(interruptId) { + const rows = await db + .select() + .from(chatInterrupts) + .where(eq(chatInterrupts.interruptId, interruptId)) + .limit(1) + return rows[0] ? mapInterrupt(rows[0]) : null + }, + list: (threadId) => listWhere(eq(chatInterrupts.threadId, threadId)), + listPending: (threadId) => + listWhere( + and( + eq(chatInterrupts.threadId, threadId), + eq(chatInterrupts.status, 'pending'), + ), + ), + listByRun: (runId) => listWhere(eq(chatInterrupts.runId, runId)), + listPendingByRun: (runId) => + listWhere( + and( + eq(chatInterrupts.runId, runId), + eq(chatInterrupts.status, 'pending'), + ), + ), + } +} + +function createMetadataStore(db: Db): MetadataStore { + return { + async get(namespace, key) { + const rows = await db + .select({ valueJson: chatMetadata.valueJson }) + .from(chatMetadata) + .where( + and(eq(chatMetadata.namespace, namespace), eq(chatMetadata.key, key)), + ) + .limit(1) + return rows[0]?.valueJson ?? null + }, + async set(namespace, key, value) { + // A JSON-mode column binds JS null as SQL NULL, which the NOT NULL + // column rejects with an opaque driver error. Fail clearly instead. + if (value == null) { + throw new TypeError( + `Cannot store ${value} for (${namespace}, ${key}) — use delete() to clear metadata.`, + ) + } + await db + .insert(chatMetadata) + .values({ namespace, key, valueJson: value }) + .onConflictDoUpdate({ + target: [chatMetadata.namespace, chatMetadata.key], + set: { valueJson: value }, + }) + }, + async delete(namespace, key) { + await db + .delete(chatMetadata) + .where( + and(eq(chatMetadata.namespace, namespace), eq(chatMetadata.key, key)), + ) + }, + } +} + +/** The four chat state stores backed by the app's Drizzle database. */ +export const chatPersistence: ChatPersistence = defineAIPersistence({ + stores: { + messages: createMessageStore(db), + runs: createRunStore(db), + interrupts: createInterruptStore(db), + metadata: createMetadataStore(db), + }, +}) +``` + +Annotate `ChatPersistence` — bare `AIPersistence` is the all-optional bag and +`withPersistence` rejects it. There is no `locks` store: `stores` accepts only +those four keys, and coordination is wired separately with `withLocks` (see +**ai-core/locks**). + +### If `db` is per-request + +Workers/D1 and any request-scoped client cannot read a binding at module scope. +Export a factory instead, and call it inside the handler: + +```ts ignore +type Db = ReturnType + +export function chatPersistence(): ChatPersistence { + const db = getDb() + return defineAIPersistence({ + stores: { + messages: createMessageStore(db), + runs: createRunStore(db), + interrupts: createInterruptStore(db), + metadata: createMetadataStore(db), + }, + }) +} +``` + +The store factories are unchanged — only the export flips from a const to a +function. For D1 specifically, see +**ai-persistence/build-cloudflare-adapter**. + +## 4. Wire it into the chat route + +```ts ignore +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { withPersistence } from '@tanstack/ai-persistence' +import { chatPersistence } from '@/lib/chat-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(chatPersistence)], + }) + return toServerSentEventsResponse(stream) +} +``` + +`threadId` is a bare string to the stores. **Authorize thread access at the +route** — derive the user from the session, never trust a client-supplied id. + +## 5. Verify + +```ts ignore +import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' +import { chatPersistence } from '../src/lib/chat-persistence' + +runPersistenceConformance('app-drizzle', () => chatPersistence) +``` + +Point it at a throwaway database (`:memory:` SQLite, a scratch schema, PGlite) +that has the migration applied, and reset between runs. Every store is +provided, so there is nothing to `skip` — and `skip` never accepts `'locks'`, +which is not a state store. + +## Only if you are publishing this as a package + +Everything above assumes the file lives in the app. If instead you are shipping +a reusable `drizzle` adapter to npm, the same store bodies apply, plus: + +- **Peer deps** `@tanstack/ai`, `@tanstack/ai-persistence`, `drizzle-orm >=0.44.0`; + dev dep `drizzle-kit`. Keep the module root free of Node built-ins so it is + edge-safe, and put any `node:sqlite` convenience factory behind a `/sqlite` + subpath. +- **Type `db` structurally** so a consumer's client is assignable: + `Pick, 'select' | 'insert' | 'update' | 'delete'>`. +- **Multi-dialect**: take a `provider: 'sqlite' | 'pg'` option, declare + overloads so `db` and `schema` must agree, and add a runtime dialect check so + a mismatched pair fails at construction rather than on first query. +- **BYO schema**: accept `drizzlePersistence(db, { schema })`, validate the + tables/columns exist at construction, and pin the required column shapes with + a compile-time contract type. +- **Never bundle SQL migrations or a runner.** Either re-export the stock tables + from a `/sqlite-schema` subpath so the consumer's `drizzle-kit` picks them up, + or emit an owned starter schema file with a small CLI. An opt-in + `ensureTables(db)` issuing `CREATE TABLE IF NOT EXISTS` is fine for local dev, + kept clearly separate from their journal. Pick one DDL owner per database. +- Run `runPersistenceConformance` once per dialect. diff --git a/packages/ai-persistence/skills/ai-persistence/build-prisma-adapter/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-prisma-adapter/SKILL.md new file mode 100644 index 000000000..b65450c22 --- /dev/null +++ b/packages/ai-persistence/skills/ai-persistence/build-prisma-adapter/SKILL.md @@ -0,0 +1,437 @@ +--- +name: ai-persistence/build-prisma-adapter +description: Use when an app already runs Prisma and needs TanStack AI chat persistence — writes a chat-persistence.ts into the app against its existing PrismaClient and schema.prisma. Covers the four models, BigInt timestamps, JSON-as-string columns, upsert-with-empty-update idempotency, and model renaming. +--- + +# Prisma Chat Persistence + +The deliverable is **one file in the app** — `src/lib/chat-persistence.ts` — +exporting a `ChatPersistence` built from the app's existing `PrismaClient`. Plus +four models added to the app's existing `schema.prisma` and a migration created +with the app's own `prisma migrate`. + +Do not create a package, a second client, a datasource block, a generator, or a +hand-written SQL migration. The app has those. + +Read the **Build Your Own Adapter** guide +(`docs/persistence/build-your-own-adapter.md`) for the store contracts and +invariants, and **ai-persistence/stores** for the shape rules. Every +store below mirrors the reference in-memory backend in +`@tanstack/ai-persistence` (`memory.ts`); the shared conformance testkit is the +proof. + +## 1. Read the app before writing anything + +| Find | Where to look | What it decides | +| -------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------- | +| Schema location | `prisma/schema.prisma`, or a multi-file `prisma/schema/` dir | Append to the existing file, or add one new `.prisma` file | +| Provider | the `datasource` block | Whether `Json` is available; nothing else changes | +| Client singleton | `src/lib/prisma.ts`, `src/db.ts`, `globalThis` dev cache | What `chat-persistence.ts` imports — never `new PrismaClient()` | +| Generated client | the `generator client` block (`output`, `prisma-client-js` vs `prisma-client`) | Where `ChatRun`/`ChatInterrupt` row types come from | +| Existing model names | the schema | Whether `Message`/`Run` are taken — prefix if so | +| Migration flow | `prisma/migrations/`, or `db push` in scripts | `prisma migrate dev` vs `prisma db push` | + +Prisma 6 and 7 both work: the delegate query API (`findUnique`, `upsert`, +`update`, `findMany`, `delete`) is unchanged, so it does not matter which +client the app generated. + +**Never invent a migration path.** Add the models, then have the user run their +own `npx prisma migrate dev --name chat-persistence` (or `db push`) and +`prisma generate`. + +## 2. Add the models to their schema + +IDs are `String`, timestamps are `BigInt` (portable epoch ms — `Int` overflows +in 2038, `DateTime` forces a conversion at every boundary), JSON payloads are +`String`. Use `@map`/`@@map` to match the app's database naming. + +```prisma +model ChatThread { + threadId String @id @map("thread_id") + messagesJson String @map("messages_json") + updatedAt BigInt @map("updated_at") + + @@map("chat_threads") +} + +model ChatRun { + 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") + + @@index([threadId, status]) + @@map("chat_runs") +} + +model ChatInterrupt { + 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") + + @@index([threadId, requestedAt]) + @@map("chat_interrupts") +} + +model ChatMetadata { + namespace String + key String + valueJson String @map("value_json") + + @@id([namespace, key]) + @@map("chat_metadata") +} +``` + +Rename models freely to fit the app — the store code below is the only thing +that references them. Extra app-owned fields (a `userId`, audit columns) are +fine as long as they are optional or defaulted, so the stores' creates still +succeed. `namespace` is the `MetadataStore` first argument; the stock SQL in +the guide calls the same column `scope`. + +On **Postgres or MySQL** you can switch the `*Json` fields to Prisma's `Json` +type and drop the `JSON.stringify`/`parse` in the mappers below. Keep `String` +if the app targets SQLite or if it is multi-provider. + +## 3. Write `src/lib/chat-persistence.ts` + +Two conversions the SQL backends do not need: `BigInt` timestamps in and out, +and JSON as strings. Everything else is the shared invariant set. + +```ts ignore +import { defineAIPersistence } from '@tanstack/ai-persistence' +import type { + ChatInterrupt, + ChatRun, + Prisma, + PrismaClient, +} from '@prisma/client' +import type { ModelMessage, TokenUsage } from '@tanstack/ai' +import type { + ChatPersistence, + InterruptRecord, + InterruptStatus, + InterruptStore, + MessageStore, + MetadataStore, + RunRecord, + RunStatus, + RunStore, +} from '@tanstack/ai-persistence' + +import { prisma } from '@/lib/prisma' + +// Trusts the shape the stores themselves wrote — nothing else writes these +// columns. +function parseJson(raw: string): T { + return JSON.parse(raw) +} + +const RUN_STATUSES: ReadonlyArray = [ + 'running', + 'completed', + 'failed', + 'interrupted', +] +const INTERRUPT_STATUSES: ReadonlyArray = [ + 'pending', + 'resolved', + 'cancelled', +] + +// The column is a plain String, so narrow instead of trusting it. +function toRunStatus(value: string): RunStatus { + const status = RUN_STATUSES.find((candidate) => candidate === value) + if (!status) throw new Error(`Unknown run status: ${value}`) + return status +} + +function toInterruptStatus(value: string): InterruptStatus { + const status = INTERRUPT_STATUSES.find((candidate) => candidate === value) + if (!status) throw new Error(`Unknown interrupt status: ${value}`) + return status +} + +// Records omit absent optionals so they compare cleanly against the reference +// in-memory backend. +function mapRun(row: ChatRun): RunRecord { + return { + runId: row.runId, + threadId: row.threadId, + status: toRunStatus(row.status), + startedAt: Number(row.startedAt), + ...(row.finishedAt != null ? { finishedAt: Number(row.finishedAt) } : {}), + ...(row.error != null ? { error: row.error } : {}), + ...(row.usageJson != null + ? { usage: parseJson(row.usageJson) } + : {}), + } +} + +function mapInterrupt(row: ChatInterrupt): InterruptRecord { + return { + interruptId: row.interruptId, + runId: row.runId, + threadId: row.threadId, + status: toInterruptStatus(row.status), + requestedAt: Number(row.requestedAt), + payload: parseJson>(row.payloadJson), + ...(row.resolvedAt != null ? { resolvedAt: Number(row.resolvedAt) } : {}), + ...(row.responseJson != null + ? { response: parseJson(row.responseJson) } + : {}), + } +} + +function createMessageStore(db: PrismaClient): MessageStore { + return { + async loadThread(threadId) { + const row = await db.chatThread.findUnique({ where: { threadId } }) + // Unknown thread is [], never null. + return row ? parseJson>(row.messagesJson) : [] + }, + // Full overwrite — `messages` is the complete authoritative transcript. + async saveThread(threadId, messages) { + const messagesJson = JSON.stringify(messages) + const updatedAt = BigInt(Date.now()) + await db.chatThread.upsert({ + where: { threadId }, + create: { threadId, messagesJson, updatedAt }, + update: { messagesJson, updatedAt }, + }) + }, + } +} + +function createRunStore(db: PrismaClient): RunStore { + return { + async get(runId) { + const row = await db.chatRun.findUnique({ where: { runId } }) + return row ? mapRun(row) : null + }, + // An empty `update` is Prisma's ON CONFLICT DO NOTHING: an existing runId + // comes back untouched, so resume and double-submit are safe. + async createOrResume({ runId, threadId, startedAt, status }) { + const row = await db.chatRun.upsert({ + where: { runId }, + create: { + runId, + threadId, + status: status ?? 'running', + startedAt: BigInt(startedAt), + }, + update: {}, + }) + return mapRun(row) + }, + // Patching an unknown runId is a no-op: never throws, never inserts. + async update(runId, patch) { + const data: Prisma.ChatRunUpdateManyMutationInput = {} + 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 + + await db.chatRun.updateMany({ where: { runId }, data }) + }, + // Optional in the contract; enables reconnect without a client-held run id. + async findActiveRun(threadId) { + const row = await db.chatRun.findFirst({ + where: { threadId, status: 'running' }, + orderBy: { startedAt: 'desc' }, + }) + return row ? mapRun(row) : null + }, + } +} + +function createInterruptStore(db: PrismaClient): InterruptStore { + // Every listing is ordered by requestedAt ascending. + const listWhere = async (where: Prisma.ChatInterruptWhereInput) => { + const rows = await db.chatInterrupt.findMany({ + where, + orderBy: { requestedAt: 'asc' }, + }) + return rows.map(mapInterrupt) + } + + return { + // Insert-if-absent: a duplicate create must never clobber a resolved + // interrupt back to pending. + async create(record) { + await db.chatInterrupt.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), + ...(record.response !== undefined + ? { responseJson: JSON.stringify(record.response) } + : {}), + }, + update: {}, + }) + }, + async resolve(interruptId, response) { + await db.chatInterrupt.updateMany({ + where: { interruptId }, + data: { + status: 'resolved', + resolvedAt: BigInt(Date.now()), + ...(response !== undefined + ? { responseJson: JSON.stringify(response) } + : {}), + }, + }) + }, + async cancel(interruptId) { + await db.chatInterrupt.updateMany({ + where: { interruptId }, + data: { status: 'cancelled', resolvedAt: BigInt(Date.now()) }, + }) + }, + async get(interruptId) { + const row = await db.chatInterrupt.findUnique({ where: { interruptId } }) + return row ? mapInterrupt(row) : null + }, + list: (threadId) => listWhere({ threadId }), + listPending: (threadId) => listWhere({ threadId, status: 'pending' }), + listByRun: (runId) => listWhere({ runId }), + listPendingByRun: (runId) => listWhere({ runId, status: 'pending' }), + } +} + +function createMetadataStore(db: PrismaClient): MetadataStore { + return { + async get(namespace, key) { + const row = await db.chatMetadata.findUnique({ + where: { namespace_key: { namespace, key } }, + }) + return row ? parseJson(row.valueJson) : null + }, + async set(namespace, key, value) { + // JSON.stringify(undefined) is undefined, which Prisma rejects against a + // required column with an opaque error. Fail clearly instead. + if (value == null) { + throw new TypeError( + `Cannot store ${value} for (${namespace}, ${key}) — use delete() to clear metadata.`, + ) + } + const valueJson = JSON.stringify(value) + await db.chatMetadata.upsert({ + where: { namespace_key: { namespace, key } }, + create: { namespace, key, valueJson }, + update: { valueJson }, + }) + }, + async delete(namespace, key) { + await db.chatMetadata.deleteMany({ where: { namespace, key } }) + }, + } +} + +/** The four chat state stores backed by the app's Prisma client. */ +export const chatPersistence: ChatPersistence = defineAIPersistence({ + stores: { + messages: createMessageStore(prisma), + runs: createRunStore(prisma), + interrupts: createInterruptStore(prisma), + metadata: createMetadataStore(prisma), + }, +}) +``` + +Notes that bite: + +- **`updateMany`, not `update`, for patches.** `update` throws + `P2025` on a missing row; the contract says a patch to an unknown id is a + silent no-op. +- **`namespace_key`** is Prisma's generated alias for the `@@id([namespace, key])` + composite. If you rename the fields, the alias name changes with them. +- Annotate `ChatPersistence` — bare `AIPersistence` is the all-optional bag and + `withPersistence` rejects it. There is no `locks` store: `stores` accepts only + those four keys, and coordination is wired separately with `withLocks` (see + **ai-core/locks**). +- If the app renamed the models, the delegate accessors are **camelCase** + (`prisma.chatThread` for `model ChatThread`), and the row types imported from + the client are PascalCase. + +## 4. Wire it into the chat route + +```ts ignore +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { withPersistence } from '@tanstack/ai-persistence' +import { chatPersistence } from '@/lib/chat-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(chatPersistence)], + }) + return toServerSentEventsResponse(stream) +} +``` + +`threadId` is a bare string to the stores. **Authorize thread access at the +route** — derive the user from the session, never trust a client-supplied id. + +## 5. Verify + +```ts ignore +import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' +import { chatPersistence } from '../src/lib/chat-persistence' + +runPersistenceConformance('app-prisma', () => chatPersistence) +``` + +Point the client at a throwaway database with the migration applied (a scratch +SQLite file is enough) and reset it between runs. All four state stores are +provided, so pass no `skip` — and `skip` never accepts `'locks'`, which is not +a state store. + +## Only if you are publishing this as a package + +Everything above assumes the file lives in the app. For a reusable npm adapter, +the same store bodies apply, plus: + +- **Peer dep** `@prisma/client >=6.7.0`. Ship no datasource, generator, + connection URL, or prebuilt SQL migration — those stay in the consumer's + schema. +- **Type the client structurally** (a `PrismaClientLike` shape) and read model + delegates off it at runtime, so Prisma 6 and 7 clients both satisfy it + regardless of where they were generated. +- **Ship the models as a raw string asset** plus a CLI + (`tanstack-ai-prisma-models`) that copies a provider-neutral fragment into the + consumer's multi-file schema directory. They then run `prisma migrate`. +- **Let consumers rename**: `prismaPersistence(prisma, { models: { messages: 'chatMessage' } })`, + where map values are the camelCase client accessors. Throw a + `PrismaModelError` naming every store whose delegate cannot be found. Keep the + field surface and the composite-id alias fixed; database names and extra + app-owned fields are theirs. +- Run `runPersistenceConformance` over a temporary SQLite database generated + from the fragment. diff --git a/packages/ai-persistence/skills/ai-persistence/server/SKILL.md b/packages/ai-persistence/skills/ai-persistence/server/SKILL.md new file mode 100644 index 000000000..43db62047 --- /dev/null +++ b/packages/ai-persistence/skills/ai-persistence/server/SKILL.md @@ -0,0 +1,178 @@ +--- +name: ai-persistence/server +description: > + Server chat state with withPersistence from @tanstack/ai-persistence. + Authoritative transcript, run lifecycle, durable interrupts/approvals, + chatParamsFromRequest, reconstructChat, snapshotStreaming. Use when the + server owns history, multi-device, or durable tool approvals. NOT client + localStorage (see ai-core/client-persistence in @tanstack/ai) and NOT + stream reconnect + alone. +type: sub-skill +library: tanstack-ai +library_version: '0.0.0' +sources: + - 'TanStack/ai:docs/persistence/chat-persistence.md' + - 'TanStack/ai:docs/persistence/overview.md' + - 'TanStack/ai:docs/persistence/controls.md' +--- + +# Server Chat Persistence + +> Builds on **ai-persistence**. Package: `@tanstack/ai-persistence`. + +`withPersistence(persistence)` is a `ChatMiddleware` that writes chat **state** +to a backend: messages, runs, interrupts (optional metadata). It does not +mutate the chunk stream and does not replace delivery durability. + +## Setup + +```ts +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { withPersistence } from '@tanstack/ai-persistence' +// Your adapter — see ai-persistence/stores. +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) +} +``` + +Always pass `threadId` and `runId` from the client (via +`chatParamsFromRequest` / body helpers). Forward `resume` when the client +resolves pending interrupts. + +For dev and tests, `memoryPersistence()` from `@tanstack/ai-persistence` is a +drop-in backend that implements all four stores in process. + +## What each store does + +| Store | Role | Required? | +| ------------ | --------------------------------------- | ----------------------------------------- | +| `messages` | Full model-message transcript load/save | **Yes** for `withPersistence` | +| `runs` | Run status, timing, usage, errors | Optional; needed for interrupt durability | +| `interrupts` | Pending/resolved tool approvals & waits | Optional; **requires** `runs` | +| `metadata` | App-owned namespaced key/value | Optional | + +Named shapes: `ChatTranscriptPersistence` (floor), `ChatPersistence` (all four). +**Annotate your factory with one of these**, not with bare `AIPersistence` — +the unparameterized type is the all-optional bag, and `withPersistence` rejects +it because `stores.messages` is possibly `undefined`. + +## Authoritative-history contract + +- **Non-empty `messages`** → finish **overwrites** the stored thread with that + array. Post the **complete** transcript, never a delta. +- **Empty `messages`** → middleware **loads** the stored thread and continues. + +## When state is written + +| Moment | Writes | Best-effort? | +| ------------------ | ----------------------------------------------------------------- | -------------------------------- | +| `onStart` | Pending turn snapshot (user + history) | Yes — failure does not abort | +| Interrupt boundary | New interrupts, run → `interrupted`, message snapshot | No | +| `onFinish` | Full transcript **first**, then run → `completed`, commit resumes | No | +| Stream (optional) | Throttled partial assistant text | Yes if `snapshotStreaming: true` | +| `onError` | Run → `failed` | Resumes stay pending | +| `onAbort` | Run → `interrupted` | Resumes stay pending | + +```ts +withPersistence(persistence, { + snapshotStreaming: true, + snapshotIntervalMs: 1000, // default +}) +``` + +Streaming snapshots default **off** (finish is authoritative). Enable only when +partial-output durability is worth extra writes. + +Resumes accepted in `onConfig` commit only at a success boundary (interrupt or +finish). A failed run leaves interrupts pending so the same resume batch can +retry. + +## Interrupt / resume flow + +1. Middleware records pending interrupts and **gates** new input: if pending + exist, the request must include a matching `resume` batch or `onConfig` + throws. +2. On valid resume, middleware builds `resumeToolState` and clears + `config.resume` so the engine does not double-reconstruct from client + history (server owns transcript). +3. On success boundary, interrupts are marked resolved/cancelled. + +## Hydrate a thread for the client (`reconstructChat`) + +Server-authoritative clients load history by `threadId` (often `GET`): + +```ts +import { reconstructChat } from '@tanstack/ai-persistence' + +export async function GET(request: Request) { + return reconstructChat(persistence, request, { + // Multi-user: required in production + authorize: async (threadId, req) => { + const userId = await sessionUserId(req) + return userOwnsThread(userId, threadId) + }, + }) +} +``` + +Returns `{ messages, activeRun, interrupts }`: + +- `messages` — UI messages for paint +- `activeRun` — `{ runId }` if a run is still generating (`runs.findActiveRun`) +- `interrupts` — pending human-in-the-loop state for re-prompt + +**Without `authorize`, anyone who guesses `?threadId=` gets the transcript.** + +## Generation activities + +`withGenerationPersistence(persistence)` tracks run records for non-chat +activities (image, audio, TTS, video, transcription). Do not fake +`threadId = requestId` on chat run stores — use the generation helper. + +## Common mistakes + +### CRITICAL: Posting a message delta as `messages` + +Wipes the stored thread down to that delta. Always send full history or `[]`. + +### HIGH: Omitting `threadId` / `runId` + +Persistence keys and resume need stable ids. Use `chatParamsFromRequest`. + +### HIGH: Interrupts without `runs` + +`interrupts` requires `runs`; `withPersistence` throws otherwise. + +### HIGH: Typing a factory as bare `AIPersistence` + +`AIPersistence` defaults to the sparse all-optional bag, so `withPersistence` +and `reconstructChat` reject the value. Return `ChatPersistence` (or +`ChatTranscriptPersistence`) instead. + +### MEDIUM: Expecting `withPersistence` to reconnect a dropped stream + +That is delivery durability (resumable streams), not state persistence. + +## Cross-references + +- **ai-persistence** — layers and recommended stack +- **ai-persistence/stores** — implement the store interfaces +- **ai-core/client-persistence** (`@tanstack/ai`) — browser half +- **ai-core/locks** — multi-instance coordination diff --git a/packages/ai-persistence/skills/ai-persistence/stores/SKILL.md b/packages/ai-persistence/skills/ai-persistence/stores/SKILL.md new file mode 100644 index 000000000..38480db3c --- /dev/null +++ b/packages/ai-persistence/skills/ai-persistence/stores/SKILL.md @@ -0,0 +1,279 @@ +--- +name: ai-persistence/stores +description: > + Implement the MessageStore, RunStore, InterruptStore, MetadataStore contracts + for @tanstack/ai-persistence against any database. defineAIPersistence, + composePersistence overrides, critical invariants (full-replace saveThread, + insert-if-absent createOrResume and interrupt create), authorize thread + access, runPersistenceConformance testkit. Use whenever you need server + persistence — the package ships contracts, not a backend for your database. +type: sub-skill +library: tanstack-ai +library_version: '0.0.0' +sources: + - 'TanStack/ai:docs/persistence/build-your-own-adapter.md' + - 'TanStack/ai:docs/persistence/controls.md' + - 'TanStack/ai:packages/ai-persistence/src/types.ts' +--- + +# Persistence Stores + +> Builds on **ai-persistence** and **ai-persistence/server**. + +`@tanstack/ai-persistence` ships **contracts**, not a backend for your +database. An adapter is an object with a `stores` map; implement the stores you +need against whatever you already run and hand the result to +`withPersistence`. The core never inspects your tables, so the schema is yours. + +Use `memoryPersistence()` for dev and tests. Everything durable is an adapter +you write. This skill is the contract reference; the per-stack recipes that +write a `chat-persistence.ts` into an app are +`ai-persistence/build-{drizzle,prisma,cloudflare,custom}-adapter`, and +a complete `node:sqlite` implementation lives in +`examples/ts-react-chat/src/lib/sqlite-persistence.ts`. + +## Choose a shape + +```ts +import { defineAIPersistence } from '@tanstack/ai-persistence' +import type { ChatWithInterruptsPersistence } from '@tanstack/ai-persistence' + +// Sparse is fine — only implement what you need. +export const persistence: ChatWithInterruptsPersistence = defineAIPersistence({ + stores: { + messages, // required for withPersistence / reconstructChat + runs, // required if you have interrupts + interrupts, + // metadata optional + }, +}) +``` + +| Shape | Contents | +| ------------------------------- | ------------------------------------------------ | +| `ChatTranscriptPersistence` | `messages` (+ optional runs/interrupts/metadata) | +| `ChatWithInterruptsPersistence` | `messages` + `runs` + `interrupts` | +| `ChatPersistence` | all four chat stores | + +`defineAIPersistence` preserves exact keys and rejects unknown keys at runtime. + +**Annotate your factory with a named shape.** Bare `AIPersistence` is the +all-optional sparse bag, so `withPersistence` and `reconstructChat` reject it +(`stores.messages` is possibly `undefined`). This is the single most common +mistake when writing an adapter. + +**`stores` accepts exactly four keys** — `messages`, `runs`, `interrupts`, +`metadata`. Anything else (notably `locks` or sandbox instance maps) throws +`Unknown AIPersistence store key` at runtime and fails to type-check. Locks: +**ai-core/locks** / `@tanstack/ai/locks`. Sandbox instance resume: +`@tanstack/ai-sandbox`. + +## Contracts and invariants + +### `MessageStore` + +```ts +interface MessageStore { + loadThread(threadId: string): Promise> + saveThread(threadId: string, messages: Array): Promise +} +``` + +- `loadThread` → `[]` for unknown threads (never `null`). +- `saveThread` is a **full overwrite**, not append. A one-message payload wipes history. + +### `RunStore` + +```ts +interface RunStore { + createOrResume(input: { + runId: string + threadId: string + status?: RunStatus + startedAt: number + }): Promise + update( + runId: string, + patch: Partial< + Pick + >, + ): Promise + get(runId: string): Promise + findActiveRun?(threadId: string): Promise // optional +} +``` + +- **`createOrResume`**: if `runId` exists, return it **unchanged** (ignore new + fields). Idempotent retries / resume depend on this. +- **`update`**: missing `runId` is a **no-op** (do not throw, do not insert). +- **`findActiveRun`**: latest `'running'` for `threadId` (max `startedAt`); + enables reconnect without a client-held run id. Optional — the middleware + feature-detects it. + +### `InterruptStore` + +```ts +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` always births `'pending'`; **insert-if-absent** on `interruptId` + (never clobber resolved back to pending). +- All `list*` ordered by `requestedAt` ascending. +- Requires a `runs` store when used with chat persistence. + +### `MetadataStore` + +```ts +interface MetadataStore { + get(namespace: string, key: string): Promise + set(namespace: string, key: string, value: unknown): Promise + delete(namespace: string, key: string): Promise +} +``` + +- The first argument is an **app-defined namespace string**, not the `Scope` + identity type — despite SQL backends conventionally naming the column + `scope`. +- Identity is **two fields** `(namespace, key)` — do not join with `:` + (`('a:b','c')` and `('a','b:c')` must stay distinct). +- Stored `null` is type-indistinguishable from absence; wrap if you must + persist real null (`{ value: null }`). +- SQL backends usually reject nullish `set` (NOT NULL JSON columns) with a + clear `TypeError` — match that or document your semantics. + +## Timestamp convention + +Store _records_ (`RunRecord`, `InterruptRecord`) speak **epoch milliseconds** +(`number`). Wire/result references that leave the persistence layer speak +**ISO-8601 strings**; the middleware converts at the boundary. Do not mix the +two on one field. + +## Minimal message store example + +Type each store with its `define*Store` helper (`defineMessageStore`, +`defineRunStore`, `defineInterruptStore`, `defineMetadataStore`): pass the object +literal and get autocomplete + contract checking inline, with no `: MessageStore` +annotation. The result composes into `defineAIPersistence` with exact presence. + +```ts +import { defineMessageStore } from '@tanstack/ai-persistence' +import type { ModelMessage } from '@tanstack/ai' + +const threads = new Map>() + +export const messages = defineMessageStore({ + async loadThread(threadId) { + return [...(threads.get(threadId) ?? [])] + }, + async saveThread(threadId, next) { + threads.set(threadId, [...next]) + }, +}) +``` + +For durable DBs, preserve the same semantics with upserts / full-row replace. + +## Adopt part of it + +You rarely need all four stores in the same system. Implement the ones you own +and fill the rest from another base with `composePersistence`: + +```ts +import { composePersistence, memoryPersistence } from '@tanstack/ai-persistence' +import { messages, runs } from './my-postgres-stores' + +export const persistence = composePersistence(memoryPersistence(), { + overrides: { messages, runs }, +}) +``` + +Only listed keys move; others stay on the base. Pass `false` to drop a store. +There is **no cross-store transaction** — if `messages` lives in Postgres and +`interrupts` in Redis, a write touching both is two writes. The store +invariants (idempotent `createOrResume`, insert-if-absent `create`) are exactly +what make those retries safe. + +`composePersistence` accepts the four state keys. Locks and sandbox instance +maps are not composable here. + +## Map onto an existing schema + +- **Your column names, your types.** Name columns anything; use `jsonb`, + `timestamptz`, whatever — convert in the row mapper. The record shape the + methods return is fixed; how you store it is not. +- **Extra columns are fine.** Add `user_id`, audit columns, a tenant id. Keep + them nullable or defaulted so the store's inserts still succeed. The stores + never read or write columns they do not know about. +- **Omit absent optionals** in row mappers (`...(row.error != null ? { error: row.error } : {})`) + so records compare cleanly. + +## Authorization + +Store methods take bare `threadId`s. **Authorize at the route** before +`loadThread` / `saveThread` / `reconstructChat({ authorize })`. Derive user +identity from session, not the client body alone. + +## Conformance tests (required) + +```ts +import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' +import { myPersistence } from '../src/persistence' + +runPersistenceConformance('my-backend', () => myPersistence()) + +// Declare intentional omissions — only the four state stores are valid keys: +// runPersistenceConformance('msgs-only', () => p, { +// skip: ['runs', 'interrupts', 'metadata'], +// }) +``` + +The testkit is the compatibility gate: round-trips, rich message shapes, +empty-thread `[]`, `createOrResume` idempotency, interrupt insert-if-absent, +list ordering, composite-key non-aliasing. A missing store that is not listed +in `skip` fails loudly. + +`skip` accepts only `'messages' | 'runs' | 'interrupts' | 'metadata'`. **Do not +pass `'locks'`** — it is not a state store and the suite does not cover it. + +Reference implementation: `memoryPersistence()` in `@tanstack/ai-persistence`. + +## Common mistakes + +### CRITICAL: Append-only `saveThread` + +Breaks the authoritative-history contract. + +### CRITICAL: `createOrResume` overwriting existing runs + +Breaks safe resume / double-submit. + +### CRITICAL: Interrupt `create` upserting to pending + +Can resurrect a resolved approval. + +### HIGH: Returning bare `AIPersistence` from the factory + +`withPersistence` rejects it. Annotate a named shape. + +### HIGH: `list*` without stable `requestedAt` order + +Middleware and tests assume ascending order. + +### HIGH: Skipping the testkit + +Silent semantic drift shows up as stuck approvals or wiped history in prod. + +## Cross-references + +- **ai-persistence/server** — when middleware calls each store +- **ai-persistence/build-drizzle-adapter** / **-prisma-** / **-cloudflare-** / **-custom-** — per-stack recipes +- **ai-core/locks** — not a state store diff --git a/packages/ai-persistence/src/capabilities.ts b/packages/ai-persistence/src/capabilities.ts new file mode 100644 index 000000000..dbf177436 --- /dev/null +++ b/packages/ai-persistence/src/capabilities.ts @@ -0,0 +1,18 @@ +/** + * Persistence capability tokens. + * + * `withPersistence` PROVIDES persistence/interrupts so later middleware can + * read durable chat state. Locks live in `@tanstack/ai/locks` (`withLocks`). + */ +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 diff --git a/packages/ai-persistence/src/index.ts b/packages/ai-persistence/src/index.ts new file mode 100644 index 000000000..9007aa83b --- /dev/null +++ b/packages/ai-persistence/src/index.ts @@ -0,0 +1,58 @@ +// Store contracts + named chat shapes +export { + composePersistence, + defineAIPersistence, + defineMessageStore, + defineRunStore, + defineInterruptStore, + defineMetadataStore, +} from './types' +export type { + MessageStore, + RunStatus, + RunRecord, + RunStore, + InterruptRecord, + InterruptStatus, + InterruptStore, + MetadataStore, + // Named product shapes (prefer these over a sparse bag) + ChatTranscriptStores, + ChatPersistenceStores, + ChatWithInterruptsStores, + ChatTranscriptPersistence, + ChatPersistence, + ChatWithInterruptsPersistence, + AIPersistence, + AIPersistenceOverrides, + ComposedAIPersistenceStores, + // Shared conversation identity from @tanstack/ai. Stores key on + // Scope.threadId; authorize multi-user access with Scope.userId/tenantId. + Scope, +} from './types' +// AIPersistenceStores is intentionally NOT re-exported — use a named chat +// shape or AIPersistence<{ messages: MessageStore, … }>. + +// Middleware (state only — locks live in @tanstack/ai as withLocks) +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 (state stores only) +export { memoryPersistence } from './memory' + +// Interrupt controller +export { createInterruptController } from './interrupts' +export type { InterruptController } from './interrupts' + +// Persistence-owned capabilities only. Locks: @tanstack/ai. +export { + PersistenceCapability, + InterruptsCapability, + getPersistence, + providePersistence, + getInterrupts, + provideInterrupts, +} from './capabilities' 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/memory.ts b/packages/ai-persistence/src/memory.ts new file mode 100644 index 000000000..70f8368f1 --- /dev/null +++ b/packages/ai-persistence/src/memory.ts @@ -0,0 +1,188 @@ +import { defineAIPersistence } from './types' +import type { ModelMessage } from '@tanstack/ai' +import type { + ChatPersistence, + 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) + } + findActiveRun(threadId: string): Promise { + const active = [...this.runs.values()] + .filter((run) => run.threadId === threadId && run.status === 'running') + .sort((a, b) => b.startedAt - a.startedAt) + return Promise.resolve(active[0] ?? null) + } +} + +function byRequestedAt(a: InterruptRecord, b: InterruptRecord): number { + return a.requestedAt - b.requestedAt +} + +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) + .sort(byRequestedAt), + ) + } + listPending(threadId: string): Promise> { + return Promise.resolve( + [...this.interrupts.values()] + .filter( + (interrupt) => + interrupt.threadId === threadId && interrupt.status === 'pending', + ) + .sort(byRequestedAt), + ) + } + listByRun(runId: string): Promise> { + return Promise.resolve( + [...this.interrupts.values()] + .filter((interrupt) => interrupt.runId === runId) + .sort(byRequestedAt), + ) + } + listPendingByRun(runId: string): Promise> { + return Promise.resolve( + [...this.interrupts.values()] + .filter( + (interrupt) => + interrupt.runId === runId && interrupt.status === 'pending', + ) + .sort(byRequestedAt), + ) + } +} + +class MemoryMetadataStore implements MetadataStore { + // Nested maps so composite identity is `(namespace, key)` without the + // `${namespace}:${key}` collision where `('a:b','c')` aliases `('a','b:c')`. + // (This parameter is an app-defined metadata namespace string — not the + // shared `Scope` identity type from `@tanstack/ai`.) + private readonly values = new Map>() + get(namespace: string, key: string): Promise { + const bucket = this.values.get(namespace) + if (!bucket || !bucket.has(key)) return Promise.resolve(null) + return Promise.resolve(bucket.get(key)) + } + set(namespace: string, key: string, value: unknown): Promise { + let bucket = this.values.get(namespace) + if (!bucket) { + bucket = new Map() + this.values.set(namespace, bucket) + } + bucket.set(key, value) + return Promise.resolve() + } + delete(namespace: string, key: string): Promise { + const bucket = this.values.get(namespace) + if (!bucket) return Promise.resolve() + bucket.delete(key) + if (bucket.size === 0) this.values.delete(namespace) + return Promise.resolve() + } +} + +/** + * In-process reference backend for **chat** state stores. + * + * Returns {@link ChatPersistence} (messages + runs + interrupts + metadata). + * Locks are not included — use `InMemoryLockStore` + `withLocks` from + * `@tanstack/ai` when a test or single-process app needs coordination. + */ +export function memoryPersistence(): ChatPersistence { + return defineAIPersistence({ + stores: { + messages: new MemoryMessageStore(), + runs: new MemoryRunStore(), + interrupts: new MemoryInterruptStore(), + metadata: new MemoryMetadataStore(), + }, + }) +} diff --git a/packages/ai-persistence/src/middleware.ts b/packages/ai-persistence/src/middleware.ts new file mode 100644 index 000000000..cd86cfab9 --- /dev/null +++ b/packages/ai-persistence/src/middleware.ts @@ -0,0 +1,682 @@ +import { defineChatMiddleware } from '@tanstack/ai' +import { + InterruptsCapability, + PersistenceCapability, + provideInterrupts, + providePersistence, +} from './capabilities' +import { + validateChatPersistenceStores, + validateGenerationPersistenceStores, +} 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, + ChatTranscriptStores, + 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 + // Apply first; only clear the in-memory stash after every resolve/cancel + // succeeds so a mid-loop store failure can still re-drive remaining ids + // if the hook is retried (or a later boundary re-enters commit). + await applyPendingResumes(pending, resumeByInterruptId, interrupts) + state.pendingResumes = undefined +} + +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 { + wantsInterrupts: boolean + runs: AIPersistence['stores']['runs'] +} + +function resolvePersistencePlan(persistence: AIPersistence): PersistencePlan { + return { + wantsInterrupts: persistence.stores.interrupts !== 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 + +/** + * Chat entrypoint invalid when: + * - `messages` is known-absent, or + * - `interrupts` is known-present without `runs`. + * + * Fully optional bags (`AIPersistence` with all `?` keys) stay assignable and + * are checked at runtime by {@link validateChatPersistenceStores}. + */ +type InvalidChatPersistence = + StoreIsDefinitelyAbsent extends true + ? true + : StoreIsDefinitelyPresent extends true + ? StoreIsDefinitelyAbsent + : false + +type ValidChatPersistence = + InvalidChatPersistence extends true ? never : unknown + +/** Generation entrypoint invalid when `runs` is known-absent. */ +type InvalidGenerationPersistence = + StoreIsDefinitelyAbsent extends true ? true : false + +type ValidGenerationPersistence = + InvalidGenerationPersistence 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 **state** persistence middleware. Provides durable transcript, + * run records, and interrupts for `chat()`. Does **not** provide locks — + * use `withLocks` from `@tanstack/ai` for multi-instance coordination. + * + * This middleware never mutates the chunk stream; delivery durability + * (replaying a disconnected/reloaded stream) is a separate transport-layer + * concern (see the resumable-streams docs). + * + * Requires `stores.messages`. When `stores.interrupts` is present, + * `stores.runs` is also required. + * + * ⚠️ 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 +} + +/** + * @param persistence - Must satisfy {@link ChatTranscriptStores} (messages + * required). Known-absent `messages` or `interrupts` without `runs` fail at + * compile time; fully dynamic bags are checked at runtime. + */ +export function withPersistence( + persistence: AIPersistence & ValidChatPersistence, + options: WithPersistenceOptions = {}, +): ChatMiddleware { + // Runtime validation covers dynamic bags that bypass the generic constraint. + validateChatPersistenceStores(persistence) + const snapshotStreaming = options.snapshotStreaming ?? false + const snapshotIntervalMs = options.snapshotIntervalMs ?? 1000 + const plan = resolvePersistencePlan(persistence) + const { wantsInterrupts, runs } = plan + const messageStore = persistence.stores.messages + if (!messageStore) { + // validateChatPersistenceStores already throws; this narrows for TypeScript. + throw new Error('Chat persistence requires stores.messages.') + } + + const provides = [ + PersistenceCapability, + ...(wantsInterrupts ? [InterruptsCapability] : []), + ] + + 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) + } + }, + + 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) + + { + const state = runState.get(ctx) + if (!state?.merged) { + if (state) state.merged = true + const stored = await messageStore.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`. + try { + await messageStore.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 && + 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 messageStore.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) + await messageStore.saveThread(ctx.threadId, [...ctx.messages]) + state.interrupted = true + }, + + async onFinish(ctx: ChatMiddlewareContext, info: FinishInfo) { + const state = runState.get(ctx) + if (state?.interrupted) return + // Transcript first: if saveThread fails the run stays non-completed and + // resumes stay pending so a retry can re-apply them. Completing the run + // or consuming approvals before the durable history lands leaves a + // "finished" run whose transcript is missing the terminal turn. + await messageStore.saveThread( + ctx.threadId, + finishedTranscript(ctx.messages, info, state?.streamingMessageId), + ) + await completeRun(runs, ctx.runId, info.usage) + await commitPendingResumes(state, persistence.stores.interrupts) + }, + + 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. + * + * Requires `stores.runs`. + * + * ⚠️ TEMPORARY / WRONG SHAPE — do not extend this design. + * + * Generation jobs must **not** fake `threadId = requestId`. `threadId` is the + * shared conversation key ({@link Scope.threadId} / chat middleware); a + * generation activity has no conversation. Dual-keying chat {@link RunStore} + * with `(runId: requestId, threadId: requestId)` pollutes chat run queries and + * confuses `findActiveRun(threadId)`. + * + * The follow-up generation-persistence work should introduce a dedicated job + * store (e.g. `GenerationJobStore` keyed by `jobId` / `requestId`) and optional + * later artifact storage — not reuse chat `RunStore` / `MessageStore`. An + * optional `threadId` on a job is only a *link* to a chat when the product + * needs it, never the job's primary identity. + */ +export function withGenerationPersistence< + TStores extends AIPersistenceStores & { runs: RunStore }, +>( + persistence: AIPersistence & ValidGenerationPersistence, +): GenerationMiddleware { + validateGenerationPersistenceStores(persistence) + const runStore = persistence.stores.runs + if (!runStore) { + // validateGenerationPersistenceStores already throws; this narrows for TypeScript. + throw new Error('Generation persistence requires stores.runs.') + } + + return { + name: 'generation-persistence', + + async onStart(ctx: GenerationMiddlewareContext) { + // STOPGAP ONLY — see function JSDoc. Do not copy this pattern. + await createOrResumeRun(runStore, ctx.requestId, ctx.requestId) + }, + + async onFinish( + ctx: GenerationMiddlewareContext, + info: GenerationFinishInfo, + ) { + await completeRun(runStore, ctx.requestId, info.usage) + }, + + async onError(ctx: GenerationMiddlewareContext, info: GenerationErrorInfo) { + await failRun(runStore, ctx.requestId, info.error) + }, + + async onAbort( + ctx: GenerationMiddlewareContext, + _info: GenerationAbortInfo, + ) { + await interruptRun(runStore, ctx.requestId) + }, + } +} diff --git a/packages/ai-persistence/src/reconstruct.ts b/packages/ai-persistence/src/reconstruct.ts new file mode 100644 index 000000000..4bc0a66a6 --- /dev/null +++ b/packages/ai-persistence/src/reconstruct.ts @@ -0,0 +1,149 @@ +import { modelMessagesToUIMessages } from '@tanstack/ai' +import type { UIMessage } from '@tanstack/ai' +import { validateReconstructChatStores } from './types' +import type { AIPersistence, ChatTranscriptStores } from './types' + +/** + * The JSON body `reconstructChat` returns and a server-authoritative client + * hydrates from on mount. + * + * `messages` is the stored transcript as UI messages (ready to paint). + * `activeRun` is a cursor to a run still generating for the thread, or `null` — + * resolved from the STABLE thread id via `stores.runs.findActiveRun`, so the + * client learns "there is a live run to tail" without ever handling a run id. + * `interrupts` is the thread's pending human-in-the-loop interrupts (tool + * approvals, client-tool/generic waits) and the run they paused, or `null` — + * so a reload (or another device) re-prompts the approval from the SERVER, not + * from client storage. Resolved via `stores.interrupts.listPending`. + */ +export interface ReconstructedChat { + messages: Array + activeRun: { runId: string } | null + interrupts: { + runId: string + pending: Array> + } | null +} + +export interface ReconstructChatOptions { + /** Query parameter carrying the thread id. Defaults to `threadId`. */ + param?: string + /** + * Authorize access to the requested thread before loading history. + * + * ⚠️ Without this, any caller who knows or guesses `?threadId=` receives the + * full transcript. Multi-user / multi-tenant deployments **must** supply + * an authorization check (session → owned threads) or resolve a validated + * thread id in the route and pass it via a custom `param` that only your + * server sets. + * + * Return: + * - `true` to allow the load + * - `false` for a default `403` response + * - a `Response` to return as-is (e.g. `401` with a body) + */ + authorize?: ( + threadId: string, + request: Request, + ) => boolean | Response | Promise +} + +/** + * Build the JSON `Response` a server-authoritative client hydrates from on load + * (see the client-persistence guide). Reads the thread id from the request query + * (`?threadId=` by default) and returns `{ messages, activeRun, interrupts }` + * ({@link ReconstructedChat}): + * + * - `messages` — the stored transcript as UI messages. + * - `activeRun` — `{ runId }` if a run is still generating for the thread (so the + * client tails it via the durability stream), else `null`. Resolved via the + * optional `stores.runs.findActiveRun`; `null` when that store/method is absent. + * - `interrupts` — `{ runId, pending }` if the thread has pending human-in-the-loop + * interrupts (a paused approval / wait) and the run they paused, else `null`, so + * a reload re-prompts the decision from the server. Resolved via the optional + * `stores.interrupts.listPending`; `null` when that store is absent. + * + * Requires `stores.messages`. Returns an empty transcript with no active run + * and no interrupts when the thread id is missing or the thread is unknown, so + * the caller never has to special-case a first load. + * + * This helper does **not** enforce tenancy by itself. Pass + * {@link ReconstructChatOptions.authorize} (or wrap the call in your own + * session gate) before exposing it on a public route. + * + * ```ts + * export async function GET(request: Request) { + * return reconstructChat(persistence, request, { + * authorize: async (threadId, req) => { + * const userId = await getSessionUserId(req) + * return userId != null && (await userOwnsThread(userId, threadId)) + * }, + * }) + * } + * ``` + */ +export async function reconstructChat( + persistence: AIPersistence, + request: Request, + options?: ReconstructChatOptions, +): Promise { + validateReconstructChatStores(persistence) + const messageStore = persistence.stores.messages + if (!messageStore) { + // validateReconstructChatStores already throws; this narrows for TypeScript. + throw new Error('reconstructChat requires stores.messages.') + } + + const param = options?.param ?? 'threadId' + const threadId = new URL(request.url).searchParams.get(param) ?? '' + + if (threadId && options?.authorize) { + const decision = await options.authorize(threadId, request) + if (decision instanceof Response) { + return decision + } + if (!decision) { + return new Response(JSON.stringify({ error: 'Forbidden' }), { + status: 403, + headers: { + 'content-type': 'application/json', + 'cache-control': 'no-store', + }, + }) + } + } + + // Resolve the active run BEFORE reading the transcript. `withPersistence` + // persists the final transcript BEFORE marking a run complete, so observing + // "no active run" here guarantees the transcript read below is the FINAL one. + // Reading them in the other order opens a finish-window race: a fast run that + // completes between the two reads would return a stale streaming snapshot with + // `activeRun: null`, leaving the client stuck on the partial (no run to tail). + const active = threadId + ? await persistence.stores.runs?.findActiveRun?.(threadId) + : null + const stored = threadId ? await messageStore.loadThread(threadId) : [] + // Pending interrupts for the thread, so a reload re-prompts the approval from + // the server. Each stored `payload` is the full interrupt descriptor the + // client hydrates; they share the run they paused. + const pending = threadId + ? ((await persistence.stores.interrupts?.listPending(threadId)) ?? []) + : [] + const firstPending = pending[0] + const body: ReconstructedChat = { + messages: modelMessagesToUIMessages(stored), + activeRun: active ? { runId: active.runId } : null, + interrupts: firstPending + ? { + runId: firstPending.runId, + pending: pending.map((record) => record.payload), + } + : null, + } + return new Response(JSON.stringify(body), { + headers: { + 'content-type': 'application/json', + 'cache-control': 'no-store', + }, + }) +} diff --git a/packages/ai-persistence/src/testkit/conformance.ts b/packages/ai-persistence/src/testkit/conformance.ts new file mode 100644 index 000000000..8e591b06d --- /dev/null +++ b/packages/ai-persistence/src/testkit/conformance.ts @@ -0,0 +1,432 @@ +/** + * Shared conformance suite for the `AIPersistence` **state** contract. + * + * Every backend runs this identical suite — the in-memory reference store and + * every adapter you write against your own database — 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`. + * + * Locks are not part of this suite — they are a separate coordination concern + * (`LockStore` + `withLocks`), not state stores. + * + * SKIPPING: a backend that deliberately omits a state store 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() + }) + + // `findActiveRun` is optional on the RunStore contract; backends that have + // not implemented it are skipped, but any backend that has must satisfy + // these invariants (most-recent-running wins, thread-scoped, null when idle). + it('findActiveRun returns the most recent running run for a thread', async () => { + const store = resolveStore('runs') + if (!store) return + if (!store.findActiveRun) return + + const thread = 'thread-active' + expect(await store.findActiveRun(thread)).toBeNull() + + await store.createOrResume({ + runId: 'active-1', + threadId: thread, + startedAt: 1000, + }) + await store.createOrResume({ + runId: 'active-2', + threadId: thread, + startedAt: 2000, + }) + // Most-recent running run wins. + expect(await store.findActiveRun(thread)).toMatchObject({ + runId: 'active-2', + status: 'running', + }) + + // A different thread's running run is not returned. + await store.createOrResume({ + runId: 'other-1', + threadId: 'thread-other', + startedAt: 3000, + }) + expect(await store.findActiveRun(thread)).toMatchObject({ + runId: 'active-2', + }) + + // Once the newest finishes, the older running run becomes active. + await store.update('active-2', { + status: 'completed', + finishedAt: 2500, + }) + expect(await store.findActiveRun(thread)).toMatchObject({ + runId: 'active-1', + status: 'running', + }) + + // With none running, it is null. + await store.update('active-1', { + status: 'completed', + finishedAt: 1500, + }) + expect(await store.findActiveRun(thread)).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) + }) + + it('lists ordered by requestedAt ascending even when inserts are out of order', async () => { + const store = resolveStore('interrupts') + if (!store) return + + // Insert later-timestamped first so Map insertion order would reverse + // requestedAt order without an explicit sort. + await store.create({ + interruptId: 'int-late', + runId: 'run-order', + threadId: 'thread-order', + requestedAt: 300, + payload: {}, + }) + await store.create({ + interruptId: 'int-early', + runId: 'run-order', + threadId: 'thread-order', + requestedAt: 100, + payload: {}, + }) + await store.create({ + interruptId: 'int-mid', + runId: 'run-order', + threadId: 'thread-order', + requestedAt: 200, + payload: {}, + }) + + expect( + (await store.list('thread-order')).map((r) => r.interruptId), + ).toEqual(['int-early', 'int-mid', 'int-late']) + expect( + (await store.listPending('thread-order')).map((r) => r.interruptId), + ).toEqual(['int-early', 'int-mid', 'int-late']) + expect( + (await store.listByRun('run-order')).map((r) => r.interruptId), + ).toEqual(['int-early', 'int-mid', 'int-late']) + }) + }) + + describe('metadata', () => { + it('sets, gets, namespaces, and deletes without composite-key collisions', 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 namespaced: scope-b untouched. + expect(await store.get('scope-b', 'k')).toEqual({ n: 2 }) + + // Composite identity must not alias across colon-containing parts. + // ('a:b','c') and ('a','b:c') are distinct pairs. + await store.set('a:b', 'c', 'left') + await store.set('a', 'b:c', 'right') + expect(await store.get('a:b', 'c')).toBe('left') + expect(await store.get('a', 'b:c')).toBe('right') + await store.delete('a:b', 'c') + expect(await store.get('a:b', 'c')).toBeNull() + expect(await store.get('a', 'b:c')).toBe('right') + }) + }) + }) +} diff --git a/packages/ai-persistence/src/types.ts b/packages/ai-persistence/src/types.ts new file mode 100644 index 000000000..a1fd7f488 --- /dev/null +++ b/packages/ai-persistence/src/types.ts @@ -0,0 +1,568 @@ +import type { ModelMessage, Scope, TokenUsage } from '@tanstack/ai' + +// Re-export the shared identity type so app code can import Scope from either +// `@tanstack/ai` or `@tanstack/ai-persistence`. See {@link Scope} security notes: +// pair a client-visible `threadId` with a server-trusted `userId`/`tenantId` +// before authorizing load/save (e.g. via `reconstructChat({ authorize })`). +export type { Scope } + +// =========================================================================== +// Store contracts +// =========================================================================== +// +// EVOLUTION POLICY +// ---------------- +// These store interfaces are the compatibility surface between the core +// middleware and every backend — the in-memory reference store and every +// adapter an application writes against its own database. 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. The key is + * {@link Scope.threadId} (the same conversation id as + * `ChatMiddlewareContext.threadId`). Store methods take a bare string for + * adapter simplicity; multi-user isolation is the **host's** job — authorize + * against `Scope.userId` / `Scope.tenantId` (derived server-side from session) + * before calling load/save, and never treat a client-supplied thread id alone + * as an ownership proof (see `Scope` security notes in `@tanstack/ai`). + * + * `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` ({@link Scope.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 **chat** run (one agent turn within a conversation). + * + * `threadId` is the conversation key ({@link Scope.threadId}) — never a + * generation `requestId`. Generation jobs must not reuse this record by + * faking `threadId = requestId`; they need a separate job store (see + * `withGenerationPersistence` JSDoc). + * + * @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 + /** Conversation this run belongs to — same as {@link Scope.threadId}. */ + 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 + /** + * The most recent `'running'` run for `threadId`, or `null` if none is active. + * + * OPTIONAL — callers feature-detect it (`store.findActiveRun?.(threadId)`) and + * degrade to "no active run" when a backend has not implemented it. + * + * This resolves "does this thread have a live run to attach to?" from the + * STABLE thread id, which is the durable basis for reconnecting a client (a + * reload, or the same thread opened on another device) — independent of the + * ephemeral run id, which a single turn may mint several of. When more than + * one run is `'running'`, the one with the greatest `startedAt` wins. + */ + findActiveRun?: (threadId: 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> +} + +/** + * Namespaced key/value store for arbitrary JSON metadata (app-owned). + * + * The first argument is an **app-defined namespace string**, not the shared + * {@link Scope} identity type from `@tanstack/ai`. Composite identity is + * `(namespace, key)` as two independent fields (SQL backends use a composite + * primary key; the in-memory store uses nested maps). Do not encode both into a + * single delimited string — `${namespace}:${key}` collides when either part + * contains `:`. + * + * The same `key` under different namespaces is independent. + */ +export interface MetadataStore { + /** + * Return the stored value for `(namespace, 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: (namespace: string, key: string) => Promise + /** Insert or overwrite the value for `(namespace, key)`. */ + set: (namespace: string, key: string, value: unknown) => Promise + /** + * Remove `(namespace, key)`. A no-op if absent. Does not affect other + * namespaces. + */ + delete: (namespace: string, key: string) => Promise +} + +// =========================================================================== +// Store typers +// =========================================================================== +// +// Identity helpers that type a store implementation inline: pass an object +// literal and get autocomplete + contract checking, with no separate +// `: MessageStore` return annotation. They compose into `defineAIPersistence`, +// which infers **exact presence** — a store you define becomes a defined, +// non-optional, autocompleted key on `persistence.stores`, and accessing a store +// you did not define is a compile error. +// +// ```ts +// const persistence = defineAIPersistence({ +// stores: { +// messages: defineMessageStore({ loadThread, saveThread }), +// runs: defineRunStore({ createOrResume, update, get }), +// }, +// }) +// persistence.stores.runs // RunStore (defined) +// persistence.stores.interrupts // compile error — not provided +// ``` + +/** Type a {@link MessageStore} implementation inline. */ +export function defineMessageStore(store: MessageStore): MessageStore { + return store +} +/** Type a {@link RunStore} implementation inline. */ +export function defineRunStore(store: RunStore): RunStore { + return store +} +/** Type an {@link InterruptStore} implementation inline. */ +export function defineInterruptStore(store: InterruptStore): InterruptStore { + return store +} +/** Type a {@link MetadataStore} implementation inline. */ +export function defineMetadataStore(store: MetadataStore): MetadataStore { + return store +} + +/** + * Sparse bag of **state** store keys — composition / validation only. + * + * **Not a public product shape.** Prefer the named chat shapes below + * ({@link ChatTranscriptStores}, {@link ChatPersistenceStores}, + * {@link ChatWithInterruptsStores}). Locks are not included — use + * `withLocks` from `@tanstack/ai`. + * + * @internal Exported from this module for generics; the package root does not + * re-export this type — use a named shape or `AIPersistence<{ … }>` instead. + */ +export interface AIPersistenceStores { + messages?: MessageStore + runs?: RunStore + interrupts?: InterruptStore + metadata?: MetadataStore +} + +/** + * Chat floor: durable transcript. `messages` is required. + * + * `runs` / `interrupts` / `metadata` remain optional. If `interrupts` is set, + * `runs` is required (enforced by `withPersistence` / validators). + */ +export interface ChatTranscriptStores { + messages: MessageStore + runs?: RunStore + interrupts?: InterruptStore + metadata?: MetadataStore +} + +/** + * Full chat durability — all four state stores are present. This is what + * `memoryPersistence()` returns, and the shape most adapters should declare. + * + * Backends that only need a transcript should use + * {@link ChatTranscriptStores} instead. + */ +export interface ChatPersistenceStores { + messages: MessageStore + runs: RunStore + interrupts: InterruptStore + metadata: MetadataStore +} + +/** + * Chat with durable human-in-the-loop interrupts (and optional metadata). + * Implies `runs` (interrupt records are run-scoped). + * + * Prefer {@link ChatPersistenceStores} when you also have metadata (packaged + * backends). Use this when interrupts are required but metadata is not. + */ +export interface ChatWithInterruptsStores { + messages: MessageStore + runs: RunStore + interrupts: InterruptStore + metadata?: MetadataStore +} + +/** + * Persistence aggregate. Parameterize with a named store shape, or a sparse + * map for composition (`defineAIPersistence` / `composePersistence`). + * + * Default is the sparse bag so untyped / dynamic bags still type-check; + * prefer {@link ChatTranscriptPersistence} or {@link ChatPersistence} at + * call sites. + */ +export interface AIPersistence< + TStores extends AIPersistenceStores = AIPersistenceStores, +> { + stores: ExactStoreKeys +} + +/** {@link AIPersistence} for {@link ChatTranscriptStores}. */ +export type ChatTranscriptPersistence = AIPersistence + +/** {@link AIPersistence} for {@link ChatPersistenceStores}. */ +export type ChatPersistence = AIPersistence + +/** {@link AIPersistence} for {@link ChatWithInterruptsStores}. */ +export type ChatWithInterruptsPersistence = + AIPersistence + +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', +] 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') +} + +/** + * Chat middleware entrypoint rules: + * - `messages` is required (chat persistence means a durable transcript) + * - `interrupts` requires `runs` (interrupt records are run-scoped) + */ +export function validateChatPersistenceStores( + persistence: AIPersistence, +): void { + validatePersistenceStoreKeys(persistence) + if (!persistence.stores.messages) { + throw new Error('Chat persistence requires stores.messages.') + } + if (persistence.stores.interrupts && !persistence.stores.runs) { + throw new Error('Chat persistence stores.interrupts requires stores.runs.') + } +} + +/** + * Generation middleware entrypoint rule: `runs` is required (run lifecycle is + * the only generation state this middleware tracks). + */ +export function validateGenerationPersistenceStores( + persistence: AIPersistence, +): void { + validatePersistenceStoreKeys(persistence) + if (!persistence.stores.runs) { + throw new Error('Generation persistence requires stores.runs.') + } +} + +/** + * Server hydrate entrypoint rule: `messages` is required. + */ +export function validateReconstructChatStores( + persistence: AIPersistence, +): void { + validatePersistenceStoreKeys(persistence) + if (!persistence.stores.messages) { + throw new Error('reconstructChat requires stores.messages.') + } +} + +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..c8b9c17d1 --- /dev/null +++ b/packages/ai-persistence/tests/capabilities.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from 'vitest' +import { EventType, chat, defineChatMiddleware } from '@tanstack/ai' +import { + InMemoryLockStore, + LocksCapability, + getLocks, + withLocks, +} from '@tanstack/ai/locks' +import type { + AnyTextAdapter, + ChatMiddlewareContext, + StreamChunk, +} from '@tanstack/ai' +import type { LockStore } from '@tanstack/ai/locks' +import { memoryPersistence } from '../src/memory' +import { withPersistence } from '../src/middleware' +import { + InterruptsCapability, + PersistenceCapability, + getInterrupts, + 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 and interrupts from withPersistence', async () => { + const persistence = memoryPersistence() + const seen: { + persistence?: AIPersistence + interrupts?: InterruptStore + } = {} + + const consumer = defineChatMiddleware({ + name: 'capability-consumer', + requires: [PersistenceCapability, InterruptsCapability], + setup(ctx: ChatMiddlewareContext) { + seen.persistence = getPersistence(ctx) + seen.interrupts = getInterrupts(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) + }) + + it('provides locks from withLocks (separate from state persistence)', async () => { + const persistence = memoryPersistence() + const locks = new InMemoryLockStore() + const seen: { locks?: LockStore } = {} + + const consumer = defineChatMiddleware({ + name: 'lock-consumer', + requires: [LocksCapability], + setup(ctx: ChatMiddlewareContext) { + 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), withLocks(locks), consumer], + }) as AsyncIterable, + ) + + expect(seen.locks).toBe(locks) + }) +}) + +describe('createInterruptController', () => { + it('delegates request/resolve/cancel/list to the underlying store', async () => { + const persistence = memoryPersistence() + const interruptStore = persistence.stores.interrupts + expect(interruptStore).toBeDefined() + const controller = createInterruptController({ + store: interruptStore, + }) + + 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 interruptStore.get('c1'))?.status).toBe('resolved') + expect((await interruptStore.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 interruptStore.get('c2'))?.status).toBe('cancelled') + }) + + it('creates interrupts in the pending state', async () => { + const persistence = memoryPersistence() + const interruptStore = persistence.stores.interrupts + expect(interruptStore).toBeDefined() + const controller = createInterruptController({ + store: interruptStore, + }) + await controller.request({ + interruptId: 'c1', + runId: 'run-c', + threadId: 'thread-c', + requestedAt: 1, + payload: {}, + }) + expect((await interruptStore.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..091382666 --- /dev/null +++ b/packages/ai-persistence/tests/error-abort.test.ts @@ -0,0 +1,353 @@ +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']) + }) + + it('marks the run interrupted when the chat is aborted', async () => { + const persistence = memoryPersistence() + const controller = new AbortController() + // Adapter that hangs after RUN_STARTED so we can abort mid-stream. + const adapter = { + kind: 'text', + name: 'mock', + model: 'test-model', + '~types': {}, + chatStream: () => + (async function* () { + yield runStarted() + await new Promise((resolve) => { + controller.signal.addEventListener('abort', () => resolve(), { + once: true, + }) + }) + })(), + structuredOutput: async () => ({ data: {}, rawText: '{}' }), + } as unknown as AnyTextAdapter + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + runId: 'abort-run', + threadId: 't1', + abortController: controller, + middleware: [withPersistence(persistence)], + }) as AsyncIterable + + const reader = (async () => { + try { + for await (const _ of stream) { + // drain until abort + } + } catch { + // abort may reject the stream + } + })() + + // Let onConfig/onStart establish the run row, then abort. + await vi.waitFor(async () => { + const run = await persistence.stores.runs!.get('abort-run') + expect(run?.status).toBe('running') + }) + controller.abort() + await reader + + const run = await persistence.stores.runs!.get('abort-run') + expect(run?.status).toBe('interrupted') + }) +}) + +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..95820b9a8 --- /dev/null +++ b/packages/ai-persistence/tests/memory.test.ts @@ -0,0 +1,163 @@ +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.metadata).toBeDefined() + }) + + it('exposes the complete state store set (no locks)', () => { + expect(Object.keys(memoryPersistence().stores).sort()).toEqual([ + 'interrupts', + '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']) + }) + + it('orders list results by requestedAt even when inserts are reverse order', async () => { + const { interrupts } = memoryPersistence().stores + await interrupts!.create({ + interruptId: 'late', + runId: 'r1', + threadId: 't1', + requestedAt: 30, + payload: {}, + }) + await interrupts!.create({ + interruptId: 'early', + runId: 'r1', + threadId: 't1', + requestedAt: 10, + payload: {}, + }) + expect((await interrupts!.list('t1')).map((i) => i.interruptId)).toEqual([ + 'early', + 'late', + ]) + }) + }) + + describe('metadata', () => { + it('keeps colon-containing namespace and key pairs distinct', async () => { + const { metadata } = memoryPersistence().stores + await metadata!.set('a:b', 'c', 'left') + await metadata!.set('a', 'b:c', 'right') + expect(await metadata!.get('a:b', 'c')).toBe('left') + expect(await metadata!.get('a', 'b:c')).toBe('right') + }) + }) + + 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..60f040141 --- /dev/null +++ b/packages/ai-persistence/tests/persistence-types.test-d.ts @@ -0,0 +1,200 @@ +import { expectTypeOf } from 'vitest' +import { + composePersistence, + defineAIPersistence, + defineInterruptStore, + defineMessageStore, + defineMetadataStore, + defineRunStore, + memoryPersistence, + withPersistence, + withGenerationPersistence, +} from '../src' +import { InMemoryLockStore, withLocks } from '@tanstack/ai/locks' +import type { LockStore } from '@tanstack/ai/locks' +import type { + AIPersistence, + ChatPersistence, + ChatTranscriptPersistence, + ChatTranscriptStores, + 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 } }) + +// @ts-expect-error locks are not a state store key +defineAIPersistence({ stores: { messages, locks } }) + +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 } }) + +// @ts-expect-error locks cannot be composed as a state store override +composePersistence(base, { overrides: { locks } }) + +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() + +// Named shapes +expectTypeOf(memoryPersistence()).toEqualTypeOf() +const transcript: ChatTranscriptPersistence = messagesOnly +void transcript +declare const fullChat: ChatPersistence +withPersistence(fullChat) + +// Chat requires messages (named floor: ChatTranscriptStores) +withPersistence(messagesOnly) +withPersistence(defineAIPersistence({ stores: { runs, interrupts, messages } })) +// @ts-expect-error chat persistence requires messages +withPersistence(defineAIPersistence({ stores: { runs } })) +// @ts-expect-error a known interrupt store requires a known run store +withPersistence(defineAIPersistence({ stores: { interrupts, messages } })) + +// Generation requires runs +withGenerationPersistence(defineAIPersistence({ stores: { runs } })) +// @ts-expect-error generation persistence requires runs +withGenerationPersistence(messagesOnly) + +const chatWithRemovedRuns = composePersistence(base, { + overrides: { runs: false }, +}) +// @ts-expect-error composition carries the missing run dependency into chat +withPersistence(chatWithRemovedRuns) + +const chatWithRemovedMessages = composePersistence(base, { + overrides: { messages: false }, +}) +// @ts-expect-error composition without messages is invalid for chat +withPersistence(chatWithRemovedMessages) + +// Sparse AIPersistence is still usable for define/compose; chat entrypoints +// need ChatTranscriptStores (messages present). +declare const sparseRunsOnly: AIPersistence<{ runs: RunStore }> +// @ts-expect-error sparse runs-only is not ChatTranscriptStores +withPersistence(sparseRunsOnly) + +declare const dynamicChat: AIPersistence +withPersistence(dynamicChat) + +// Locks are a separate middleware, not a store key +expectTypeOf(withLocks(locks)).not.toBeNever() +expectTypeOf(withLocks(new InMemoryLockStore())).not.toBeNever() +// memoryPersistence is ChatPersistence (no locks key) +expectTypeOf(memoryPersistence().stores).not.toHaveProperty('locks') + +// --------------------------------------------------------------------------- +// Per-store typers: identity helpers that type an implementation inline and +// compose into defineAIPersistence with exact presence. +// --------------------------------------------------------------------------- +expectTypeOf(defineMessageStore(messages)).toEqualTypeOf() +expectTypeOf(defineRunStore(runs)).toEqualTypeOf() +expectTypeOf(defineInterruptStore(interrupts)).toEqualTypeOf() +expectTypeOf(defineMetadataStore(metadata)).toEqualTypeOf() + +// A store impl missing a contract method is rejected at the typer. +defineMessageStore( + // @ts-expect-error saveThread is required by MessageStore + { loadThread: () => Promise.resolve([]) }, +) + +// Composed into defineAIPersistence: defined stores are exact / non-optional, +// omitted stores are a compile error to access. +const typedStores = defineAIPersistence({ + stores: { + messages: defineMessageStore(messages), + runs: defineRunStore(runs), + interrupts: defineInterruptStore(interrupts), + }, +}) +expectTypeOf(typedStores.stores.interrupts).toEqualTypeOf() +expectTypeOf(typedStores.stores.runs).toEqualTypeOf() +// @ts-expect-error metadata was not provided +typedStores.stores.metadata 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..7b53bfddc --- /dev/null +++ b/packages/ai-persistence/tests/persistence-validation.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { withGenerationPersistence, withPersistence } from '../src/middleware' +import { reconstructChat } from '../src/reconstruct' +import { defineAIPersistence } from '../src/types' +import type { ChatTranscriptPersistence } from '../src/types' +import { + createInterruptStore, + createMessageStore, + createRunStore, +} from './persistence-fixtures' + +/** Cast for intentional runtime-misconfiguration tests. */ +function asChatTranscript( + persistence: ReturnType, +): ChatTranscriptPersistence { + return persistence as ChatTranscriptPersistence +} + +describe('persistence store dependency validation', () => { + it('rejects chat persistence without messages', () => { + const persistence = defineAIPersistence({ + stores: { runs: createRunStore() }, + }) + + expect(() => withPersistence(asChatTranscript(persistence))).toThrow( + /requires stores\.messages/i, + ) + }) + + it('rejects a dynamic chat persistence with interrupts but no runs', () => { + const persistence = defineAIPersistence({ + stores: { + messages: createMessageStore(), + interrupts: createInterruptStore(), + }, + }) + + expect(() => withPersistence(asChatTranscript(persistence))).toThrow( + /interrupts.*stores\.runs/i, + ) + }) + + it('allows message-only chat persistence', () => { + const messages = defineAIPersistence({ + stores: { messages: createMessageStore() }, + }) + + expect(() => withPersistence(messages)).not.toThrow() + }) + + it('allows a dynamic chat persistence with paired run and interrupt stores', () => { + const persistence = defineAIPersistence({ + stores: { + messages: createMessageStore(), + runs: createRunStore(), + interrupts: createInterruptStore(), + }, + }) + + expect(() => withPersistence(persistence)).not.toThrow() + }) + + it('rejects generation persistence without runs', () => { + const persistence = defineAIPersistence({ + stores: { messages: createMessageStore() }, + }) + + expect(() => + withGenerationPersistence( + persistence as Parameters[0], + ), + ).toThrow(/requires stores\.runs/i) + }) + + it('allows generation persistence with runs', () => { + const persistence = defineAIPersistence({ + stores: { runs: createRunStore() }, + }) + + expect(() => withGenerationPersistence(persistence)).not.toThrow() + }) + + it('rejects reconstructChat without messages', async () => { + const persistence = defineAIPersistence({ + stores: { runs: createRunStore() }, + }) + + await expect( + reconstructChat( + asChatTranscript(persistence), + new Request('http://example.test/api/chat?threadId=t1'), + ), + ).rejects.toThrow(/requires stores\.messages/i) + }) +}) diff --git a/packages/ai-persistence/tests/reconstruct.test.ts b/packages/ai-persistence/tests/reconstruct.test.ts new file mode 100644 index 000000000..8a5091c10 --- /dev/null +++ b/packages/ai-persistence/tests/reconstruct.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest' +import { memoryPersistence } from '../src/memory' +import { reconstructChat } from '../src/reconstruct' +import type { ReconstructedChat } from '../src/reconstruct' + +async function body(response: Response): Promise { + return (await response.json()) as ReconstructedChat +} + +function textOf(message: ReconstructedChat['messages'][number]): string { + const part = message.parts.find((p) => p.type === 'text') + return part && 'content' in part ? (part.content ?? '') : '' +} + +describe('reconstructChat', () => { + it('returns the stored transcript (as UI messages) for a known threadId', async () => { + const persistence = memoryPersistence() + await persistence.stores.messages!.saveThread('t1', [ + { role: 'user', content: 'hello' }, + { role: 'assistant', content: 'hi' }, + ]) + + const response = await reconstructChat( + persistence, + new Request('http://example.test/api/chat?threadId=t1'), + ) + expect(response.status).toBe(200) + expect(response.headers.get('cache-control')).toBe('no-store') + const parsed = await body(response) + expect(parsed.messages).toHaveLength(2) + expect(parsed.messages[0]?.role).toBe('user') + expect(textOf(parsed.messages[0]!)).toBe('hello') + // No run is generating for the thread. + expect(parsed.activeRun).toBeNull() + }) + + it('reports the active run for a thread that is still generating', async () => { + const persistence = memoryPersistence() + await persistence.stores.messages!.saveThread('t1', [ + { role: 'user', content: 'write a long story' }, + ]) + await persistence.stores.runs!.createOrResume({ + runId: 'run-live', + threadId: 't1', + startedAt: 1000, + }) + + const response = await reconstructChat( + persistence, + new Request('http://example.test/api/chat?threadId=t1'), + ) + const parsed = await body(response) + expect(parsed.activeRun).toEqual({ runId: 'run-live' }) + + // Once the run finishes, no active run is reported. + await persistence.stores.runs!.update('run-live', { status: 'completed' }) + const after = await body( + await reconstructChat( + persistence, + new Request('http://example.test/api/chat?threadId=t1'), + ), + ) + expect(after.activeRun).toBeNull() + }) + + it('returns an empty transcript and no active run when threadId is missing or unknown', async () => { + const persistence = memoryPersistence() + const missing = await body( + await reconstructChat( + persistence, + new Request('http://example.test/api/chat'), + ), + ) + expect(missing).toEqual({ messages: [], activeRun: null, interrupts: null }) + + const unknown = await body( + await reconstructChat( + persistence, + new Request('http://example.test/api/chat?threadId=nope'), + ), + ) + expect(unknown).toEqual({ messages: [], activeRun: null, interrupts: null }) + }) + + it('reports pending interrupts so a reload re-prompts the approval', async () => { + const persistence = memoryPersistence() + await persistence.stores.messages!.saveThread('t1', [ + { role: 'user', content: 'send an email' }, + ]) + const payload = { + id: 'int-1', + type: 'tool-approval', + toolName: 'sendEmail', + toolCallId: 'call-1', + } + await persistence.stores.interrupts!.create({ + interruptId: 'int-1', + runId: 'run-paused', + threadId: 't1', + requestedAt: 1000, + payload, + }) + + const parsed = await body( + await reconstructChat( + persistence, + new Request('http://example.test/api/chat?threadId=t1'), + ), + ) + expect(parsed.interrupts).toEqual({ + runId: 'run-paused', + pending: [payload], + }) + }) + + it('returns 403 when authorize returns false', async () => { + const persistence = memoryPersistence() + await persistence.stores.messages!.saveThread('t1', [ + { role: 'user', content: 'secret' }, + ]) + + const response = await reconstructChat( + persistence, + new Request('http://example.test/api/chat?threadId=t1'), + { authorize: () => false }, + ) + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: 'Forbidden' }) + }) + + it('returns a custom Response from authorize', async () => { + const persistence = memoryPersistence() + const response = await reconstructChat( + persistence, + new Request('http://example.test/api/chat?threadId=t1'), + { + authorize: () => + new Response(JSON.stringify({ error: 'login' }), { status: 401 }), + }, + ) + expect(response.status).toBe(401) + }) + + it('honors a custom query param name', async () => { + const persistence = memoryPersistence() + await persistence.stores.messages!.saveThread('custom-id', [ + { role: 'user', content: 'via-param' }, + ]) + const response = await reconstructChat( + persistence, + new Request('http://example.test/api/chat?id=custom-id'), + { param: 'id' }, + ) + const parsed = await body(response) + expect(textOf(parsed.messages[0]!)).toBe('via-param') + }) +}) 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..77d846e02 --- /dev/null +++ b/packages/ai-persistence/tests/with-persistence.test.ts @@ -0,0 +1,385 @@ +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', + role: 'assistant' as const, + 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..732f38afc 100644 --- a/packages/ai-preact/src/index.ts +++ b/packages/ai-preact/src/index.ts @@ -16,6 +16,16 @@ export type { export { fetchServerSentEvents, + localStoragePersistence, + sessionStoragePersistence, + indexedDBPersistence, + StorageUnavailableError, + type ChatClientPersistence, + type ChatPersistedState, + type ChatPersistenceOption, + type ChatStorageAdapter, + type WebStoragePersistenceOptions, + type IndexedDBPersistenceOptions, fetchHttpStream, xhrServerSentEvents, xhrHttpStream, diff --git a/packages/ai-preact/src/types.ts b/packages/ai-preact/src/types.ts index 51d245201..b47112d61 100644 --- a/packages/ai-preact/src/types.ts +++ b/packages/ai-preact/src/types.ts @@ -72,6 +72,10 @@ export type UseChatOptions< | 'onResumeStateChange' | 'context' | 'devtools' + // `id` is not a hook option: the hook's identity is its `threadId`, which is + // also the persistence key. Persist across reloads by passing a stable + // `threadId`; there is no separate id to set. + | 'id' > & { live?: boolean /** Display options for TanStack AI Devtools. */ diff --git a/packages/ai-preact/src/use-chat.ts b/packages/ai-preact/src/use-chat.ts index da00800a7..3dcd4f7b2 100644 --- a/packages/ai-preact/src/use-chat.ts +++ b/packages/ai-preact/src/use-chat.ts @@ -38,8 +38,12 @@ export function useChat< const TTools extends ReadonlyArray = any, TContext = InferredClientContext, >(options: UseChatOptions): UseChatReturn { + // The hook's identity is its `threadId` — also the persistence key, so a + // reload with the same `threadId` restores the same conversation. `hookId` is + // only a stable fallback for client-recreation keying when no `threadId` is + // given (an ephemeral chat), never a persistence key. const hookId = useId() - const clientId = options.id || hookId + const clientId = options.threadId ?? hookId const [messages, setMessages] = useState>>( options.initialMessages || [], @@ -118,7 +122,6 @@ export function useChat< const instance = new ChatClient({ devtoolsBridgeFactory: createChatDevtoolsBridge, ...transport, - id: clientId, initialMessages: messagesToUse, ...(initialOptions.body !== undefined && { body: initialOptions.body }), ...(initialOptions.threadId !== undefined && { diff --git a/packages/ai-preact/tests/use-chat.test.ts b/packages/ai-preact/tests/use-chat.test.ts index 0e1063cd8..149a59224 100644 --- a/packages/ai-preact/tests/use-chat.test.ts +++ b/packages/ai-preact/tests/use-chat.test.ts @@ -170,7 +170,7 @@ describe('useChat', () => { const { result } = renderUseChat({ connection: adapter, - id: 'persisted-chat', + threadId: 'persisted-chat', persistence: persistence, }) @@ -198,7 +198,7 @@ describe('useChat', () => { const { result } = renderUseChat({ connection: adapter, - id: 'persisted-empty-chat', + threadId: 'persisted-empty-chat', initialMessages, persistence: persistence, }) @@ -239,7 +239,7 @@ describe('useChat', () => { const [id, setId] = useState('old-chat') const chat = useChat({ connection: createMockConnectionAdapter(), - id, + threadId: id, persistence: persistence, }) @@ -264,13 +264,13 @@ describe('useChat', () => { expect(result.current.messages).toEqual(newMessages) }) - it('should use provided id', async () => { + it('should use provided threadId', async () => { const chunks = createTextChunks('Response') const adapter = createMockConnectionAdapter({ chunks }) const { result } = renderUseChat({ connection: adapter, - id: 'custom-id', + threadId: 'custom-id', }) await act(async () => { @@ -1103,15 +1103,15 @@ describe('useChat', () => { } const { result, rerender } = renderHook( - (opts: { id: string; onChunk: (chunk: StreamChunk) => void }) => + (opts: { threadId: string; onChunk: (chunk: StreamChunk) => void }) => useChat({ connection: adapter, - id: opts.id, + threadId: opts.threadId, onChunk: opts.onChunk, }), { initialProps: { - id: 'old-client', + threadId: 'old-client', onChunk: oldOnChunk, }, }, @@ -1126,7 +1126,7 @@ describe('useChat', () => { }) rerender({ - id: 'new-client', + threadId: 'new-client', onChunk: newOnChunk, }) @@ -1222,7 +1222,7 @@ describe('useChat', () => { const { result } = renderHook(() => { const [id, setId] = useState('client-A') - const chat = useChat({ connection: adapter, id }) + const chat = useChat({ connection: adapter, threadId: id }) return { ...chat, switchId: setId } }) @@ -1281,7 +1281,7 @@ describe('useChat', () => { const { result } = renderHook(() => { const [id, setId] = useState('client-A') - const chat = useChat({ connection: adapter, id }) + const chat = useChat({ connection: adapter, threadId: id }) return { ...chat, switchId: setId } }) @@ -1560,11 +1560,11 @@ describe('useChat', () => { const { result: result1 } = renderUseChat({ connection: adapter1, - id: 'chat-1', + threadId: 'chat-1', }) const { result: result2 } = renderUseChat({ connection: adapter2, - id: 'chat-2', + threadId: 'chat-2', }) await act(async () => { @@ -1590,11 +1590,11 @@ describe('useChat', () => { const adapter = createMockConnectionAdapter() const { result: result1 } = renderUseChat({ connection: adapter, - id: 'chat-1', + threadId: 'chat-1', }) const { result: result2 } = renderUseChat({ connection: adapter, - id: 'chat-2', + threadId: 'chat-2', }) // Should not interfere with each other diff --git a/packages/ai-react/src/index.ts b/packages/ai-react/src/index.ts index 773c05456..75bff7c3c 100644 --- a/packages/ai-react/src/index.ts +++ b/packages/ai-react/src/index.ts @@ -69,6 +69,16 @@ export type { // Re-export from ai-client for convenience export { fetchServerSentEvents, + localStoragePersistence, + sessionStoragePersistence, + indexedDBPersistence, + StorageUnavailableError, + type ChatClientPersistence, + type ChatPersistedState, + type ChatPersistenceOption, + type ChatStorageAdapter, + type WebStoragePersistenceOptions, + type IndexedDBPersistenceOptions, fetchHttpStream, xhrServerSentEvents, xhrHttpStream, diff --git a/packages/ai-react/src/types.ts b/packages/ai-react/src/types.ts index 3cee013c9..72c4f94be 100644 --- a/packages/ai-react/src/types.ts +++ b/packages/ai-react/src/types.ts @@ -96,6 +96,10 @@ export type UseChatOptions< | 'onResumeStateChange' | 'context' | 'devtools' + // `id` is not a hook option: the hook's identity is its `threadId`, which is + // also the persistence key. Persist across reloads by passing a stable + // `threadId`; there is no separate id to set. + | 'id' > & { /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions diff --git a/packages/ai-react/src/use-chat.ts b/packages/ai-react/src/use-chat.ts index 05bc5c24d..b34ca45b7 100644 --- a/packages/ai-react/src/use-chat.ts +++ b/packages/ai-react/src/use-chat.ts @@ -39,8 +39,12 @@ export function useChat< >( options: UseChatOptions, ): UseChatReturn { + // The hook's identity is its `threadId` — also the persistence key, so a + // reload with the same `threadId` restores the same conversation. `hookId` is + // only a stable fallback for React's client-recreation keying when no + // `threadId` is given (an ephemeral chat), never a persistence key. const hookId = useId() - const clientId = options.id || hookId + const clientId = options.threadId ?? hookId const [messages, setMessages] = useState>>( options.initialMessages || [], @@ -73,6 +77,7 @@ export function useChat< options.initialMessages || [], ) const isFirstMountRef = useRef(true) + const subscribedRef = useRef(false) const activeClientRef = useRef(null) const cleanupInvalidationRef = useRef | null>( null, @@ -123,7 +128,6 @@ export function useChat< const instance = new ChatClient({ devtoolsBridgeFactory: createChatDevtoolsBridge, ...transport, - id: clientId, initialMessages: messagesToUse, ...(initialOptions.body !== undefined && { body: initialOptions.body }), ...(initialOptions.threadId !== undefined && { @@ -279,8 +283,17 @@ export function useChat< useEffect(() => { if (options.live) { client.subscribe() - } else { + subscribedRef.current = true + } else if (subscribedRef.current) { + // Only tear down a subscription we actually started. Calling + // `unsubscribe()` on initial mount (when `live` was never enabled) would + // abort an in-flight delivery resume — `resumeInFlightRun` is kicked off + // in the client constructor, and `unsubscribe()` cancels the shared + // in-flight stream — so a mid-stream reload would drop its rejoin before + // it delivers a single chunk. This is exactly why a reload froze instead + // of continuing. client.unsubscribe() + subscribedRef.current = false } }, [client, options.live]) @@ -307,18 +320,22 @@ export function useChat< } cleanupInvalidationRef.current = null }, 0) - // Subscribe/unsubscribe on `options.live` is owned by the dedicated - // effect above. This cleanup only fires on unmount or client swap, - // so read `live` through the ref to avoid disposing the client every - // time `live` toggles. - if (optionsRef.current.live) { - client.unsubscribe() - } else { - client.stop() - } + // Soft cleanup only: do NOT stop/unsubscribe here. React Strict Mode + // remounts fire this cleanup then re-attach the same client one tick + // later; calling `stop()` would abort a constructor rejoin + // (`resumeInFlightRun`) and can wipe the durable resume pointer before + // the first chunk. Real teardown lives in the deferred dispose path + // below, which only runs when the client is not remounted. + // Subscribe/unsubscribe on `options.live` is still owned by the + // dedicated effect above for live toggles. const disposal = { client, timeout: setTimeout(() => { + if (optionsRef.current.live) { + client.unsubscribe() + } else { + client.stop() + } client.dispose() if (cleanupDisposalRef.current === disposal) { cleanupDisposalRef.current = null diff --git a/packages/ai-react/tests/use-chat.test.ts b/packages/ai-react/tests/use-chat.test.ts index d141601a2..f0c288a2c 100644 --- a/packages/ai-react/tests/use-chat.test.ts +++ b/packages/ai-react/tests/use-chat.test.ts @@ -11,7 +11,11 @@ import { createToolCallChunks, renderUseChat, } from './test-utils' -import type { SubscribeConnectionAdapter } from '@tanstack/ai-client' +import type { + ChatClientPersistence, + ResumableConnectConnectionAdapter, + SubscribeConnectionAdapter, +} from '@tanstack/ai-client' import type { UIMessage, UseChatOptions } from '../src/types' import type { ModelMessage, StreamChunk } from '@tanstack/ai' @@ -169,7 +173,7 @@ describe('useChat', () => { const { result } = renderUseChat({ connection: adapter, - id: 'persisted-chat', + threadId: 'persisted-chat', persistence: persistence, }) @@ -197,7 +201,7 @@ describe('useChat', () => { const { result } = renderUseChat({ connection: adapter, - id: 'persisted-empty-chat', + threadId: 'persisted-empty-chat', initialMessages, persistence: persistence, }) @@ -235,10 +239,10 @@ describe('useChat', () => { } function useChangingChat() { - const [id, setId] = useState('old-chat') + const [threadId, setId] = useState('old-chat') const chat = useChat({ connection: createMockConnectionAdapter(), - id, + threadId, persistence: persistence, }) @@ -263,13 +267,13 @@ describe('useChat', () => { expect(result.current.messages).toEqual(newMessages) }) - it('should use provided id', async () => { + it('should use provided threadId', async () => { const chunks = createTextChunks('Response') const adapter = createMockConnectionAdapter({ chunks }) const { result } = renderUseChat({ connection: adapter, - id: 'custom-id', + threadId: 'custom-id', }) await result.current.sendMessage('Test') @@ -326,6 +330,159 @@ describe('useChat', () => { expect(typeof result.current.resumeInterrupts).toBe('function') expect(result.current.resumeState).toBeNull() }) + + it('rejoins an in-flight run on mount without aborting it (live off)', async () => { + // Regression: the mount effect used to call `client.unsubscribe()` when + // `live` was false, which aborted the delivery resume the constructor had + // just kicked off — so a reload dropped the rejoin before it delivered a + // chunk. The rejoined run must stream through to the message list. + const runChunks: Array = [ + { + type: EventType.RUN_STARTED, + runId: 'r1', + threadId: 't1', + timestamp: 1, + }, + { + type: EventType.TEXT_MESSAGE_START, + messageId: 'a1', + role: 'assistant', + timestamp: 2, + }, + { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: 'a1', + delta: 'resumed reply', + timestamp: 3, + }, + { type: EventType.TEXT_MESSAGE_END, messageId: 'a1', timestamp: 4 }, + { + type: EventType.RUN_FINISHED, + runId: 'r1', + threadId: 't1', + finishReason: 'stop', + timestamp: 5, + }, + ] + const joinRun = vi.fn(async function* (_runId: string) { + for (const chunk of runChunks) yield chunk + }) + const connection: ResumableConnectConnectionAdapter = { + connect: async function* () {}, + joinRun, + } + // Server-authoritative store: no cached messages, only a resume pointer. + const persistence: ChatClientPersistence = { + getItem: () => ({ + messages: [], + resume: { + schemaVersion: 2, + resumeState: { threadId: 't1', runId: 'r1' }, + }, + }), + setItem: () => {}, + removeItem: () => {}, + } + + const { result } = renderUseChat({ + connection, + threadId: 't1', + persistence, + }) + + await waitFor(() => { + const assistant = result.current.messages.find( + (m) => m.role === 'assistant', + ) + const text = assistant?.parts.find((p) => p.type === 'text') + expect(text && 'content' in text && text.content).toBe('resumed reply') + }) + }) + + it('rejoins under StrictMode remount without aborting the constructor rejoin', async () => { + // Soft-dispose cleanup must not call stop() — Strict Mode remount reuses + // the same client one tick later, and stop() would abort rejoin + wipe + // the resume pointer before the first chunk. + let releaseFirstChunk!: () => void + const firstChunkGate = new Promise((resolve) => { + releaseFirstChunk = resolve + }) + const joinRun = vi.fn(async function* (_runId: string) { + await firstChunkGate + const chunks: Array = [ + { + type: EventType.RUN_STARTED, + runId: 'r1', + threadId: 't1', + timestamp: 1, + }, + { + type: EventType.TEXT_MESSAGE_START, + messageId: 'a1', + role: 'assistant', + timestamp: 2, + }, + { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: 'a1', + delta: 'strict-ok', + timestamp: 3, + }, + { type: EventType.TEXT_MESSAGE_END, messageId: 'a1', timestamp: 4 }, + { + type: EventType.RUN_FINISHED, + runId: 'r1', + threadId: 't1', + finishReason: 'stop', + timestamp: 5, + }, + ] + for (const chunk of chunks) yield chunk + }) + const connection: ResumableConnectConnectionAdapter = { + connect: async function* () {}, + joinRun, + } + const persistence: ChatClientPersistence = { + getItem: () => ({ + messages: [], + resume: { + schemaVersion: 2, + resumeState: { threadId: 't1', runId: 'r1' }, + }, + }), + setItem: () => {}, + removeItem: () => {}, + } + + const { result } = renderHook( + () => + useChat({ + connection, + threadId: 't1', + persistence, + }), + { wrapper: StrictMode }, + ) + + // Let Strict Mode mount → cleanup → remount complete before chunks flow. + await act(async () => { + await Promise.resolve() + releaseFirstChunk() + }) + + await waitFor(() => { + const assistant = result.current.messages.find( + (m) => m.role === 'assistant', + ) + const text = assistant?.parts.find((p) => p.type === 'text') + expect(text && 'content' in text && text.content).toBe('strict-ok') + }) + // Strict Mode may construct more than one client in dev; what matters is + // rejoin completed (content above) and was not aborted before attach. + expect(joinRun).toHaveBeenCalled() + expect(joinRun).toHaveBeenCalledWith('r1', expect.anything()) + }) }) describe('state synchronization', () => { @@ -1050,7 +1207,7 @@ describe('useChat', () => { { initialProps: { connection: adapter, - id: 'old-client', + threadId: 'old-client', onChunk: oldOnChunk, }, }, @@ -1063,7 +1220,7 @@ describe('useChat', () => { rerender({ connection: adapter, - id: 'new-client', + threadId: 'new-client', onChunk: newOnChunk, }) @@ -1162,8 +1319,8 @@ describe('useChat', () => { // Control id via state so setMessages and setId are both React // state updates that get batched into a single render. const { result } = renderHook(() => { - const [id, setId] = useState('client-A') - const chat = useChat({ connection: adapter, id }) + const [threadId, setId] = useState('client-A') + const chat = useChat({ connection: adapter, threadId }) return { ...chat, switchId: setId } }) @@ -1225,8 +1382,8 @@ describe('useChat', () => { ] const { result } = renderHook(() => { - const [id, setId] = useState('client-A') - const chat = useChat({ connection: adapter, id }) + const [threadId, setId] = useState('client-A') + const chat = useChat({ connection: adapter, threadId }) return { ...chat, switchId: setId } }) @@ -1454,11 +1611,11 @@ describe('useChat', () => { const { result: result1 } = renderUseChat({ connection: adapter1, - id: 'chat-1', + threadId: 'chat-1', }) const { result: result2 } = renderUseChat({ connection: adapter2, - id: 'chat-2', + threadId: 'chat-2', }) await result1.current.sendMessage('Hello 1') @@ -1482,11 +1639,11 @@ describe('useChat', () => { const adapter = createMockConnectionAdapter() const { result: result1 } = renderUseChat({ connection: adapter, - id: 'chat-1', + threadId: 'chat-1', }) const { result: result2 } = renderUseChat({ connection: adapter, - id: 'chat-2', + threadId: 'chat-2', }) // Should not interfere with each other diff --git a/packages/ai-sandbox/package.json b/packages/ai-sandbox/package.json index 924b25279..09796516a 100644 --- a/packages/ai-sandbox/package.json +++ b/packages/ai-sandbox/package.json @@ -19,7 +19,8 @@ "agent", "coding-agent", "isolation", - "workspace" + "workspace", + "tanstack-intent" ], "type": "module", "module": "./dist/esm/index.js", diff --git a/packages/ai-sandbox/src/capabilities.ts b/packages/ai-sandbox/src/capabilities.ts index 8abd7f95d..9353db9e5 100644 --- a/packages/ai-sandbox/src/capabilities.ts +++ b/packages/ai-sandbox/src/capabilities.ts @@ -1,16 +1,15 @@ /** - * Capability tokens the sandbox layer provides/consumes through the - * `@tanstack/ai` middleware capability system. + * Capability tokens the sandbox layer owns and provides. * * - `SandboxCapability` is PROVIDED by `withSandbox` and REQUIRED by harness * adapters (`requires: [SandboxCapability]`). - * - `SandboxStoreCapability` / `LocksCapability` are OPTIONALLY required by - * `withSandbox`. v1 falls back to in-memory defaults; the future persistence - * package PROVIDES durable implementations. + * - `SandboxStoreCapability` is OPTIONALLY required by `withSandbox` (in-memory + * fallback when absent). + * - `LocksCapability` lives in `@tanstack/ai/locks` and is not re-exported here. */ import { createCapability } from '@tanstack/ai' import type { SandboxHandle } from './contracts' -import type { LockStore, SandboxStore } from './store' +import type { SandboxStore } from './store' import type { SandboxPolicy } from './policy' import type { ToolBridgeProvisioner } from './tool-bridge' @@ -19,8 +18,6 @@ export const SandboxCapability = createCapability()('sandbox') export const SandboxStoreCapability = createCapability()('sandbox-store') -export const LocksCapability = createCapability()('locks') - /** * The active sandbox policy, provided by `withSandbox` from the definition. * Harness adapters read it to map allow/ask/deny rules onto their native @@ -41,7 +38,6 @@ export const ToolBridgeProvisionerCapability = /** Destructured accessors for adapters: `getSandbox(ctx)` reads the handle. */ export const [getSandbox, provideSandbox] = SandboxCapability export const [getSandboxStore, provideSandboxStore] = SandboxStoreCapability -export const [getLocks, provideLocks] = LocksCapability export const [getSandboxPolicy, provideSandboxPolicy] = SandboxPolicyCapability export const [getToolBridgeProvisioner, provideToolBridgeProvisioner] = ToolBridgeProvisionerCapability diff --git a/packages/ai-sandbox/src/index.ts b/packages/ai-sandbox/src/index.ts index 2c5bf2855..51698d831 100644 --- a/packages/ai-sandbox/src/index.ts +++ b/packages/ai-sandbox/src/index.ts @@ -1,16 +1,14 @@ -// Capability tokens + accessors +// Capability tokens + accessors (sandbox-owned only). +// LockStore / withLocks: import from @tanstack/ai. export { SandboxCapability, SandboxStoreCapability, - LocksCapability, SandboxPolicyCapability, ToolBridgeProvisionerCapability, getSandbox, provideSandbox, getSandboxStore, provideSandboxStore, - getLocks, - provideLocks, getSandboxPolicy, provideSandboxPolicy, getToolBridgeProvisioner, @@ -100,9 +98,9 @@ export type { SandboxDestroyInput, } from './contracts' -// Stores (interfaces + in-memory defaults) -export { InMemorySandboxStore, InMemoryLockStore } from './store' -export type { SandboxStore, LockStore, SandboxRecord } from './store' +// Instance map (interfaces + in-memory default) +export { InMemorySandboxStore } from './store' +export type { SandboxStore, SandboxRecord } from './store' // Bootstrap engine (exported for provider/adapter authors + tests) export { diff --git a/packages/ai-sandbox/src/middleware.ts b/packages/ai-sandbox/src/middleware.ts index d25314429..58b5b4886 100644 --- a/packages/ai-sandbox/src/middleware.ts +++ b/packages/ai-sandbox/src/middleware.ts @@ -3,10 +3,10 @@ * {@link SandboxCapability} a harness adapter requires. * * - `setup`: resume-or-create the sandbox (via the definition's ensure - * algorithm), provide the handle, using the optional SandboxStore/Locks - * capabilities when a persistence middleware supplied them (in-memory - * fallback otherwise). If `fileEvents` is not false, starts a watcher - * that dispatches to sandbox-scoped hooks and forwards to the runtime sink. + * algorithm), provide the handle, using optional SandboxStoreCapability / + * LocksCapability when provided earlier (in-memory fallback otherwise). + * If `fileEvents` is not false, starts a watcher that dispatches to + * sandbox-scoped hooks and forwards to the runtime sink. * - `onFinish`/`onAbort`/`onError`: stop the watcher, snapshot (`after-run`) * and/or destroy per lifecycle. * @@ -15,9 +15,9 @@ * chunks), not from here — middleware setup runs before streaming begins. */ import { defineChatMiddleware } from '@tanstack/ai' +import { LocksCapability } from '@tanstack/ai/locks' import { getSandboxRuntime } from '@tanstack/ai/adapter-internals' import { - LocksCapability, SandboxCapability, SandboxStoreCapability, provideSandbox, diff --git a/packages/ai-sandbox/src/sandbox.ts b/packages/ai-sandbox/src/sandbox.ts index b1f20859e..bd13aba27 100644 --- a/packages/ai-sandbox/src/sandbox.ts +++ b/packages/ai-sandbox/src/sandbox.ts @@ -6,14 +6,16 @@ * into a stable instance key and coordinates through the (optional) lock + * sandbox stores. */ +import { InMemoryLockStore } from '@tanstack/ai/locks' +import type { LockStore } from '@tanstack/ai/locks' +import type { SandboxFileHookEvent } from '@tanstack/ai' import { bootstrapWorkspace } from './bootstrap' import { resolveAllSecrets } from './secrets' import { computeSandboxKey } from './key' -import { InMemoryLockStore, InMemorySandboxStore } from './store' -import type { SandboxFileHookEvent } from '@tanstack/ai' +import { InMemorySandboxStore } from './store' import type { SandboxHandle, SandboxProvider } from './contracts' import type { SandboxKeyInput } from './key' -import type { LockStore, SandboxStore } from './store' +import type { SandboxStore } from './store' import type { SandboxPolicy } from './policy' import type { WorkspaceDefinition } from './workspace' diff --git a/packages/ai-sandbox/src/store.ts b/packages/ai-sandbox/src/store.ts index 7371cfbd6..72d8255e6 100644 --- a/packages/ai-sandbox/src/store.ts +++ b/packages/ai-sandbox/src/store.ts @@ -1,13 +1,12 @@ /** - * Persistence seams for the sandbox layer. + * Sandbox instance map (in-memory for single-process resume). * - * v1 ships ONLY in-memory implementations (single-process resume). These are - * deliberately pluggable OPTIONAL capabilities so the future persistence - * package can `provide` durable implementations (D1/Postgres/Durable Objects) - * without the sandbox layer changing. Do NOT hardcode storage here. + * Durable multi-process resume is a follow-up on the sandbox package + * (`SandboxInstanceStore` / `withSandboxInstanceStore`). Locks live in + * `@tanstack/ai` (`LockStore` / `withLocks`) — not here. */ -/** One persisted sandbox instance, keyed by the compound sandbox instance key. */ +/** One sandbox instance, keyed by the compound sandbox instance key. */ export interface SandboxRecord { /** Compound key (see computeSandboxKey). */ key: string @@ -19,7 +18,7 @@ export interface SandboxRecord { latestSnapshotId?: string threadId: string latestRunId?: string - /** Epoch ms of last write (for keepAlive / GC by the persistence layer). */ + /** Epoch ms of last write (for keepAlive / GC by the host). */ updatedAt: number } @@ -30,15 +29,6 @@ export interface SandboxStore { delete: (key: string) => Promise } -/** - * Mutual exclusion around sandbox ensure so two concurrent runs for the same - * thread don't both create a sandbox. The in-memory default is single-process; - * the persistence layer provides a distributed lock (e.g. a Durable Object). - */ -export interface LockStore { - withLock: (key: string, fn: () => Promise) => Promise -} - /** In-memory {@link SandboxStore}. Resume works only within one process. */ export class InMemorySandboxStore implements SandboxStore { private readonly map = new Map() @@ -57,27 +47,3 @@ export class InMemorySandboxStore implements SandboxStore { return Promise.resolve() } } - -/** - * In-memory {@link LockStore} — a per-key promise chain. Correct within a - * single process; multi-instance correctness needs a distributed lock from the - * persistence layer. - */ -export class InMemoryLockStore implements LockStore { - private readonly chains = new Map>() - - withLock(key: string, fn: () => Promise): Promise { - const prior = this.chains.get(key) ?? Promise.resolve() - // Chain after the prior holder regardless of how it settled. - const run = prior.then(fn, fn) - // 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-sandbox/tests/ensure.test.ts b/packages/ai-sandbox/tests/ensure.test.ts index 1de55787c..820aac92f 100644 --- a/packages/ai-sandbox/tests/ensure.test.ts +++ b/packages/ai-sandbox/tests/ensure.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { defineSandbox } from '../src/sandbox' import { defineWorkspace, githubRepo } from '../src/workspace' -import { InMemoryLockStore, InMemorySandboxStore } from '../src/store' +import { InMemoryLockStore } from '@tanstack/ai/locks' +import { InMemorySandboxStore } from '../src/store' import { FULL_CAPS, makeFakeProvider } from './fakes' import type { SandboxCapabilities } from '../src/contracts' diff --git a/packages/ai-sandbox/tests/store.test.ts b/packages/ai-sandbox/tests/store.test.ts index 184d0d807..310a69d82 100644 --- a/packages/ai-sandbox/tests/store.test.ts +++ b/packages/ai-sandbox/tests/store.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { InMemoryLockStore, InMemorySandboxStore } from '../src/store' +import { InMemorySandboxStore } from '../src/store' describe('InMemorySandboxStore', () => { it('round-trips upsert/get/delete', async () => { @@ -17,48 +17,3 @@ describe('InMemorySandboxStore', () => { expect(await store.get('k')).toBeNull() }) }) - -describe('InMemoryLockStore', () => { - it('serializes same-key critical sections', async () => { - const locks = new InMemoryLockStore() - const order: Array = [] - const slow = (tag: string, ms: number) => - locks.withLock('k', async () => { - order.push(`${tag}:start`) - await new Promise((r) => setTimeout(r, ms)) - order.push(`${tag}:end`) - }) - - await Promise.all([slow('a', 20), slow('b', 1)]) - - // b cannot start until a fully ends. - expect(order).toEqual(['a:start', 'a:end', 'b:start', 'b:end']) - }) - - it('a rejection in one holder does not poison the lock', async () => { - const locks = new InMemoryLockStore() - await expect( - locks.withLock('k', () => Promise.reject(new Error('boom'))), - ).rejects.toThrow('boom') - // subsequent acquire still works - await expect( - locks.withLock('k', () => Promise.resolve('ok')), - ).resolves.toBe('ok') - }) - - it('runs different keys concurrently', async () => { - const locks = new InMemoryLockStore() - const order: Array = [] - await Promise.all([ - locks.withLock('a', async () => { - await new Promise((r) => setTimeout(r, 20)) - order.push('a') - }), - locks.withLock('b', async () => { - order.push('b') - }), - ]) - // b (different key) finishes first despite a starting first - expect(order).toEqual(['b', 'a']) - }) -}) diff --git a/packages/ai-solid/src/index.ts b/packages/ai-solid/src/index.ts index 1d0c517b3..86b39b403 100644 --- a/packages/ai-solid/src/index.ts +++ b/packages/ai-solid/src/index.ts @@ -62,6 +62,16 @@ export type { // Re-export from ai-client for convenience export { fetchServerSentEvents, + localStoragePersistence, + sessionStoragePersistence, + indexedDBPersistence, + StorageUnavailableError, + type ChatClientPersistence, + type ChatPersistedState, + type ChatPersistenceOption, + type ChatStorageAdapter, + type WebStoragePersistenceOptions, + type IndexedDBPersistenceOptions, fetchHttpStream, xhrServerSentEvents, xhrHttpStream, diff --git a/packages/ai-solid/src/types.ts b/packages/ai-solid/src/types.ts index d06442b96..d2d235aaf 100644 --- a/packages/ai-solid/src/types.ts +++ b/packages/ai-solid/src/types.ts @@ -92,6 +92,10 @@ export type UseChatOptions< | 'onResumeStateChange' | 'context' | 'devtools' + // `id` is not a hook option: the hook's identity is its `threadId`, which is + // also the persistence key. Persist across reloads by passing a stable + // `threadId`; there is no separate id to set. + | 'id' > & { /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions diff --git a/packages/ai-solid/src/use-chat.ts b/packages/ai-solid/src/use-chat.ts index c6cc505a0..7f39a72b9 100644 --- a/packages/ai-solid/src/use-chat.ts +++ b/packages/ai-solid/src/use-chat.ts @@ -50,8 +50,12 @@ export function useChat< TContext >, ): UseChatReturn { + // The hook's identity is its `threadId` — also the persistence key, so a + // reload with the same `threadId` restores the same conversation. `hookId` is + // only a stable fallback for client-recreation keying when no `threadId` is + // given (an ephemeral chat), never a persistence key. const hookId = createUniqueId() - const clientId = options.id || hookId + const clientId = options.threadId ?? hookId const [messages, setMessages] = createSignal>>( options.initialMessages || [], @@ -105,7 +109,6 @@ export function useChat< return new ChatClient({ devtoolsBridgeFactory: createChatDevtoolsBridge, ...transport, - id: clientId, ...(options.initialMessages !== undefined && { initialMessages: options.initialMessages, }), diff --git a/packages/ai-solid/tests/use-chat.test.ts b/packages/ai-solid/tests/use-chat.test.ts index 5c3271ba8..fe0fcd48b 100644 --- a/packages/ai-solid/tests/use-chat.test.ts +++ b/packages/ai-solid/tests/use-chat.test.ts @@ -154,7 +154,7 @@ describe('useChat', () => { const { result } = renderUseChat({ connection: adapter, - id: 'persisted-chat', + threadId: 'persisted-chat', persistence: persistence, }) @@ -182,7 +182,7 @@ describe('useChat', () => { const { result } = renderUseChat({ connection: adapter, - id: 'persisted-empty-chat', + threadId: 'persisted-empty-chat', initialMessages, persistence: persistence, }) @@ -193,13 +193,13 @@ describe('useChat', () => { expect(result.current.messages).toEqual([]) }) - it('should use provided id', async () => { + it('should use provided threadId', async () => { const chunks = createTextChunks('Response') const adapter = createMockConnectionAdapter({ chunks }) const { result } = renderUseChat({ connection: adapter, - id: 'custom-id', + threadId: 'custom-id', }) await result.current.sendMessage('Test') @@ -1176,11 +1176,11 @@ describe('useChat', () => { const { result: result1 } = renderUseChat({ connection: adapter1, - id: 'chat-1', + threadId: 'chat-1', }) const { result: result2 } = renderUseChat({ connection: adapter2, - id: 'chat-2', + threadId: 'chat-2', }) await result1.current.sendMessage('Hello 1') @@ -1204,11 +1204,11 @@ describe('useChat', () => { const adapter = createMockConnectionAdapter() const { result: result1 } = renderUseChat({ connection: adapter, - id: 'chat-1', + threadId: 'chat-1', }) const { result: result2 } = renderUseChat({ connection: adapter, - id: 'chat-2', + threadId: 'chat-2', }) // Should not interfere with each other diff --git a/packages/ai-svelte/src/create-chat.svelte.ts b/packages/ai-svelte/src/create-chat.svelte.ts index 874551088..bf557c8f3 100644 --- a/packages/ai-svelte/src/create-chat.svelte.ts +++ b/packages/ai-svelte/src/create-chat.svelte.ts @@ -68,11 +68,6 @@ export function createChat< >( options: CreateChatOptions, ): CreateChatReturn { - // Generate a unique ID for this chat instance - const clientId = - options.id || - `chat-${Date.now()}-${Math.random().toString(36).substring(7)}` - // Create reactive state using Svelte 5 runes let messages = $state>>(options.initialMessages || []) let isLoading = $state(false) @@ -113,10 +108,12 @@ export function createChat< ? { connection: options.connection } : { fetcher: options.fetcher } + // The hook's identity is its `threadId`, which ChatClient also uses as the + // persistence key — no separate `id`. When no `threadId` is given the client + // generates one, so an ephemeral chat still works but is not restored on reload. const client = new ChatClient({ devtoolsBridgeFactory: createChatDevtoolsBridge, ...transport, - id: clientId, ...(options.initialMessages !== undefined && { initialMessages: options.initialMessages, }), diff --git a/packages/ai-svelte/src/index.ts b/packages/ai-svelte/src/index.ts index 151fdab5d..efe617ebe 100644 --- a/packages/ai-svelte/src/index.ts +++ b/packages/ai-svelte/src/index.ts @@ -65,6 +65,16 @@ export type { // Re-export from ai-client for convenience export { fetchServerSentEvents, + localStoragePersistence, + sessionStoragePersistence, + indexedDBPersistence, + StorageUnavailableError, + type ChatClientPersistence, + type ChatPersistedState, + type ChatPersistenceOption, + type ChatStorageAdapter, + type WebStoragePersistenceOptions, + type IndexedDBPersistenceOptions, fetchHttpStream, xhrServerSentEvents, xhrHttpStream, diff --git a/packages/ai-svelte/src/types.ts b/packages/ai-svelte/src/types.ts index 8279269a8..70b971c94 100644 --- a/packages/ai-svelte/src/types.ts +++ b/packages/ai-svelte/src/types.ts @@ -91,6 +91,10 @@ export type CreateChatOptions< | 'onResumeStateChange' | 'context' | 'devtools' + // `id` is not a hook option: the hook's identity is its `threadId`, which is + // also the persistence key. Persist across reloads by passing a stable + // `threadId`; there is no separate id to set. + | 'id' > & { live?: boolean /** Display options for TanStack AI Devtools. */ diff --git a/packages/ai-svelte/tests/use-chat.test.ts b/packages/ai-svelte/tests/use-chat.test.ts index 40afe7ee0..e9ee4b732 100644 --- a/packages/ai-svelte/tests/use-chat.test.ts +++ b/packages/ai-svelte/tests/use-chat.test.ts @@ -151,7 +151,7 @@ describe('createChat', () => { const chat = createChat({ connection: mockConnection, - id: 'persisted-chat', + threadId: 'persisted-chat', persistence: persistence, }) @@ -177,7 +177,7 @@ describe('createChat', () => { const chat = createChat({ connection: mockConnection, - id: 'persisted-chat', + threadId: 'persisted-chat', initialMessages, persistence: persistence, }) diff --git a/packages/ai-vue/src/index.ts b/packages/ai-vue/src/index.ts index 1d0c517b3..86b39b403 100644 --- a/packages/ai-vue/src/index.ts +++ b/packages/ai-vue/src/index.ts @@ -62,6 +62,16 @@ export type { // Re-export from ai-client for convenience export { fetchServerSentEvents, + localStoragePersistence, + sessionStoragePersistence, + indexedDBPersistence, + StorageUnavailableError, + type ChatClientPersistence, + type ChatPersistedState, + type ChatPersistenceOption, + type ChatStorageAdapter, + type WebStoragePersistenceOptions, + type IndexedDBPersistenceOptions, fetchHttpStream, xhrServerSentEvents, xhrHttpStream, diff --git a/packages/ai-vue/src/types.ts b/packages/ai-vue/src/types.ts index 5a97f904a..df944a69b 100644 --- a/packages/ai-vue/src/types.ts +++ b/packages/ai-vue/src/types.ts @@ -93,6 +93,10 @@ export type UseChatOptions< | 'onResumeStateChange' | 'context' | 'devtools' + // `id` is not a hook option: the hook's identity is its `threadId`, which is + // also the persistence key. Persist across reloads by passing a stable + // `threadId`; there is no separate id to set. + | 'id' > & { /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions diff --git a/packages/ai-vue/src/use-chat.ts b/packages/ai-vue/src/use-chat.ts index 29f0721ef..d93cee8be 100644 --- a/packages/ai-vue/src/use-chat.ts +++ b/packages/ai-vue/src/use-chat.ts @@ -6,7 +6,6 @@ import { onScopeDispose, readonly, shallowRef, - useId, watch, } from 'vue' import type { @@ -50,9 +49,6 @@ export function useChat< TContext >, ): UseChatReturn { - const hookId = useId() // Available in Vue 3.5+ - const clientId = options.id || hookId - const messages = shallowRef>>( options.initialMessages || [], ) @@ -98,10 +94,12 @@ export function useChat< ? { connection: options.connection } : { fetcher: options.fetcher } + // The hook's identity is its `threadId`, which ChatClient also uses as the + // persistence key — no separate `id`. When no `threadId` is given the client + // generates one, so an ephemeral chat still works but is not restored on reload. const client = new ChatClient({ devtoolsBridgeFactory: createChatDevtoolsBridge, ...transport, - id: clientId, ...(options.initialMessages !== undefined && { initialMessages: options.initialMessages, }), diff --git a/packages/ai-vue/tests/use-chat.test.ts b/packages/ai-vue/tests/use-chat.test.ts index 956dc4b4e..6e92f4b25 100644 --- a/packages/ai-vue/tests/use-chat.test.ts +++ b/packages/ai-vue/tests/use-chat.test.ts @@ -153,7 +153,7 @@ describe('useChat', () => { const { result } = renderUseChat({ connection: adapter, - id: 'persisted-chat', + threadId: 'persisted-chat', persistence: persistence, }) await flushPromises() @@ -180,7 +180,7 @@ describe('useChat', () => { const { result } = renderUseChat({ connection: adapter, - id: 'persisted-chat', + threadId: 'persisted-chat', initialMessages, persistence: persistence, }) @@ -190,13 +190,13 @@ describe('useChat', () => { expect(persistence.getItem).toHaveBeenCalledWith('persisted-chat') }) - it('should use provided id', async () => { + it('should use provided threadId', async () => { const chunks = createTextChunks('Response') const adapter = createMockConnectionAdapter({ chunks }) const { result } = renderUseChat({ connection: adapter, - id: 'custom-id', + threadId: 'custom-id', }) await result.current.sendMessage('Test') @@ -1106,11 +1106,11 @@ describe('useChat', () => { const { result: result1 } = renderUseChat({ connection: adapter1, - id: 'chat-1', + threadId: 'chat-1', }) const { result: result2 } = renderUseChat({ connection: adapter2, - id: 'chat-2', + threadId: 'chat-2', }) await result1.current.sendMessage('Hello 1') @@ -1133,11 +1133,11 @@ describe('useChat', () => { const adapter = createMockConnectionAdapter() const { result: result1 } = renderUseChat({ connection: adapter, - id: 'chat-1', + threadId: 'chat-1', }) const { result: result2 } = renderUseChat({ connection: adapter, - id: 'chat-2', + threadId: 'chat-2', }) // Should not interfere with each other diff --git a/packages/ai/package.json b/packages/ai/package.json index 31cf76284..23284ece9 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -29,6 +29,10 @@ "types": "./dist/esm/client.d.ts", "import": "./dist/esm/client.js" }, + "./locks": { + "types": "./dist/esm/locks.d.ts", + "import": "./dist/esm/locks.js" + }, "./adapters": { "types": "./dist/esm/activities/index.d.ts", "import": "./dist/esm/activities/index.js" diff --git a/packages/ai/skills/ai-core/SKILL.md b/packages/ai/skills/ai-core/SKILL.md index 4bf9b64b8..2b2399262 100644 --- a/packages/ai/skills/ai-core/SKILL.md +++ b/packages/ai/skills/ai-core/SKILL.md @@ -3,9 +3,10 @@ name: ai-core description: > Entry point for TanStack AI skills. Routes to chat-experience, tool-calling, media-generation, structured-outputs, adapter-configuration, ag-ui-protocol, - middleware, custom-backend-integration, and debug-logging. Use chat() not - streamText(), openaiText() not createOpenAI(), toServerSentEventsResponse() - not manual SSE, middleware hooks not onEnd callbacks. + middleware, locks, custom-backend-integration, and debug-logging, plus the skills + shipped by companion packages (@tanstack/ai-persistence, @tanstack/ai-code-mode). + Use chat() not streamText(), openaiText() not createOpenAI(), + toServerSentEventsResponse() not manual SSE, middleware hooks not onEnd callbacks. type: core library: tanstack-ai library_version: '0.10.0' @@ -21,18 +22,64 @@ Always import from the framework package on the client — never from ## Sub-Skills -| Need to... | Read | -| ------------------------------------------------- | ------------------------------------------- | -| Build a chat UI with streaming | ai-core/chat-experience/SKILL.md | -| Add tool calling (server, client, or both) | ai-core/tool-calling/SKILL.md | -| Generate images, video, speech, or transcriptions | ai-core/media-generation/SKILL.md | -| Get typed JSON responses from the LLM | ai-core/structured-outputs/SKILL.md | -| Choose and configure a provider adapter | ai-core/adapter-configuration/SKILL.md | -| Implement AG-UI streaming protocol server-side | ai-core/ag-ui-protocol/SKILL.md | -| Add analytics, logging, or lifecycle hooks | ai-core/middleware/SKILL.md | -| Connect to a non-TanStack-AI backend | ai-core/custom-backend-integration/SKILL.md | -| Turn on/off debug logging, pipe into pino/winston | ai-core/debug-logging/SKILL.md | -| Set up Code Mode (LLM code execution) | See `@tanstack/ai-code-mode` package skills | +| Need to... | Read | +| ------------------------------------------------- | --------------------------------------------- | +| Build a chat UI with streaming | ai-core/chat-experience/SKILL.md | +| Survive a browser reload (no extra package) | ai-core/client-persistence/SKILL.md | +| Add tool calling (server, client, or both) | ai-core/tool-calling/SKILL.md | +| Generate images, video, speech, or transcriptions | ai-core/media-generation/SKILL.md | +| Get typed JSON responses from the LLM | ai-core/structured-outputs/SKILL.md | +| Choose and configure a provider adapter | ai-core/adapter-configuration/SKILL.md | +| Implement AG-UI streaming protocol server-side | ai-core/ag-ui-protocol/SKILL.md | +| Add analytics, logging, or lifecycle hooks | ai-core/middleware/SKILL.md | +| Coordinate multi-instance work with locks | ai-core/locks/SKILL.md | +| Connect to a non-TanStack-AI backend | ai-core/custom-backend-integration/SKILL.md | +| Turn on/off debug logging, pipe into pino/winston | ai-core/debug-logging/SKILL.md | +| Persist chats server-side (history, runs) | See `@tanstack/ai-persistence` package skills | +| Set up Code Mode (LLM code execution) | See `@tanstack/ai-code-mode` package skills | + +## Companion packages + +Some capabilities live in their own package and ship their own skills. Install +the package, then read its skills — do not guess the API from this file. + +### `@tanstack/ai-persistence` — durable chat state + +Makes a conversation survive a reload, a server restart, a second device, or a +paused tool approval. It ships the **store contracts** (`MessageStore`, +`RunStore`, `InterruptStore`, `MetadataStore`), the `withPersistence` / +`withGenerationPersistence` middleware, `reconstructChat` for server-side +hydrate, an in-memory reference backend, and a conformance testkit. Multi-instance +locks are **not** in this package — `LockStore` / `withLocks` ship in +`@tanstack/ai/locks`; see ai-core/locks. + +It does **not** ship a backend for your database — you implement the stores +against Postgres, SQLite, D1, Mongo, or whatever you run, and the package's +skills walk you through it (including Drizzle, Prisma, and Cloudflare recipes). + +```bash +pnpm add @tanstack/ai-persistence +npx @tanstack/intent@latest install +``` + +The skills ship **inside** the package, so they only exist on disk once it is +installed — the second command re-scans `node_modules` and wires them into the +agent config. Until then the paths below resolve to nothing. + +Entry point: `node_modules/@tanstack/ai-persistence/skills/ai-persistence/SKILL.md` + +| Need to... | Read | +| ----------------------------------------------- | --------------------------------------- | +| Wire server-side chat history, runs, interrupts | ai-persistence/server/SKILL.md | +| Implement the store interfaces for your DB | ai-persistence/stores/SKILL.md | +| Write the adapter for the DB your app runs | ai-persistence/build-*-adapter/SKILL.md | + +Browser-side persistence is **not** in this package — it ships with the +framework packages, so read **ai-core/client-persistence** instead. + +### `@tanstack/ai-code-mode` — LLM code execution + +See the `ai-code-mode` skill in that package. ## Quick Decision Tree @@ -43,6 +90,7 @@ Always import from the framework package on the client — never from - Choosing/configuring a provider? → ai-core/adapter-configuration - Building a server-only AG-UI backend? → ai-core/ag-ui-protocol - Adding analytics or post-stream events? → ai-core/middleware +- Surviving reloads / multi-device / durable approvals? → `@tanstack/ai-persistence` skills - Connecting to a custom backend? → ai-core/custom-backend-integration - Turning on debug logging to trace chunks/tools/middleware? → ai-core/debug-logging - Debugging mistakes? → Check Common Mistakes in the relevant sub-skill diff --git a/packages/ai/skills/ai-core/chat-experience/SKILL.md b/packages/ai/skills/ai-core/chat-experience/SKILL.md index b6a114345..1a8e37162 100644 --- a/packages/ai/skills/ai-core/chat-experience/SKILL.md +++ b/packages/ai/skills/ai-core/chat-experience/SKILL.md @@ -17,6 +17,7 @@ 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/persistence/client-persistence.md' --- # Chat Experience @@ -485,6 +486,73 @@ 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 `true` +(server-authoritative): the client caches nothing, and on mount it hydrates the +thread from the server by `threadId` (transcript plus a cursor to any run still +generating). An adapter is client-authoritative; `true` leaves history on the +server and needs a connection with a `hydrate` handler plus a server GET +endpoint (`reconstructChat`), 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 +`threadId`, which is the chat's identity) 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) @@ -696,3 +764,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: **`@tanstack/ai-persistence` skills** (`skills/ai-persistence/SKILL.md` in that package) -- Server + client state persistence, store contracts, adapter recipes (deeper than Pattern 8) diff --git a/packages/ai/skills/ai-core/client-persistence/SKILL.md b/packages/ai/skills/ai-core/client-persistence/SKILL.md new file mode 100644 index 000000000..3e6eb9b3b --- /dev/null +++ b/packages/ai/skills/ai-core/client-persistence/SKILL.md @@ -0,0 +1,142 @@ +--- +name: ai-core/client-persistence +description: > + Browser chat persistence on useChat / ChatClient: localStoragePersistence, + sessionStoragePersistence, indexedDBPersistence. Client-authoritative + (adapter, full transcript) vs server-authoritative (persistence: true, no + client cache). + Reload restore, pending interrupts, mid-stream rejoin with delivery + durability. Use for SPA reload durability — NOT server history alone. + No extra package: the adapters ship in the framework packages. +type: sub-skill +library: tanstack-ai +library_version: '0.10.0' +sources: + - 'TanStack/ai:docs/persistence/client-persistence.md' + - 'TanStack/ai:docs/persistence/overview.md' +--- + +# Client Persistence + +> Builds on ai-core, and on `ai-core/chat-experience` for `useChat` itself. +> +> **No extra package.** The adapters below ship in the **framework** packages +> (`@tanstack/ai-react` and friends, re-exported from `@tanstack/ai-client`), +> so browser persistence needs nothing installed beyond what a chat UI already +> has. The **server** half is a separate package — see +> `@tanstack/ai-persistence` and its `ai-persistence/server` skill. + +A `ChatClient` / `useChat` keeps messages in memory. The `persistence` option +stores one record per `threadId` so a reload can repaint the transcript, +restore a pending interrupt, and rejoin an in-flight run. + +Import adapters from the **framework package** (not `@tanstack/ai-client` +unless vanilla JS): + +```tsx +import { + useChat, + fetchServerSentEvents, + localStoragePersistence, + sessionStoragePersistence, + indexedDBPersistence, +} from '@tanstack/ai-react' +``` + +## Adapters + +| Adapter | Survives | Notes | +| ----------------------------- | -------------------------- | --------------------------------------------------------------- | +| `localStoragePersistence()` | Reloads + browser restarts | Sync hydrate; quota-bound; JSON codec default | +| `sessionStoragePersistence()` | Reloads in the same tab | Cleared when tab/session ends | +| `indexedDBPersistence()` | Reloads + restarts | Async open (first paint may be empty briefly); structured clone | + +All default to the chat persisted-state shape — no type argument or codec +required for normal use. + +## Mode A — cache everything (client-authoritative) + +```tsx +function Chat() { + const { messages, sendMessage } = useChat({ + threadId: 'support-chat', // stable — required + connection: fetchServerSentEvents('/api/chat'), + persistence: localStoragePersistence(), + }) + // ... +} +``` + +Bare adapter ≡ full transcript + resume pointer. Browser owns history; server +(if any) mirrors when you post non-empty `messages`. + +Best for: SPA, offline-first, single device, moderate conversation size. + +## Mode B — server-authoritative (`persistence: true`) + +```tsx +function Chat({ threadId }: { threadId: string }) { + const { messages, sendMessage } = useChat({ + threadId, + connection: fetchServerSentEvents('/api/chat'), + persistence: true, + }) + // ... +} +``` + +Nothing is cached client-side: no transcript, no resume pointer. + +On mount, `useChat` hydrates the thread from the **server** by `threadId` +(paint + tail active run). Same path for another device. Pair with server +`withPersistence` + a hydrate route (`reconstructChat` or equivalent). + +Best for: large transcripts, multi-device, compliance (no message bodies in +browser storage). + +## What a reload restores + +1. **Finished run** — transcript from the adapter (mode A) or server (mode B). +2. **Paused on interrupt** — approval UI restored (from the adapter in mode A, + the server hydrate in mode B). +3. **Still streaming** — needs **delivery durability** on the route + (`toServerSentEventsResponse(stream, { durability: … })`) so the client can + `joinRun` and finish the reply. Persistence alone is not enough. + +## Stable `threadId` is the identity + +Persistence keys on `threadId`. The hooks have **no separate `id` option** — a +chat's identity _is_ its `threadId`. Without a stable one, each load is a new +chat. Generate it server-side or from a route param the user owns; do not +randomize per mount. + +## Common mistakes + +### HIGH: No `threadId` + +Record cannot be found after reload. + +### HIGH: Passing `id` to `useChat` + +Removed — `threadId` is the identity. (`ChatClient` still accepts `id` directly +as a lower-level escape hatch for keying storage separately from the wire +thread; the framework hooks do not.) + +### HIGH: `persistence: true` without server history + +Empty chat after reload unless the server can reconstruct by `threadId`. + +### MEDIUM: Huge transcripts in `localStorage` + +Quota and main-thread cost. Prefer `persistence: true` + server store, or +IndexedDB with care. + +### MEDIUM: Expecting multi-device sync from client storage alone + +`localStorage` is per-browser. Use server persistence for multi-device. + +## Cross-references + +- **ai-persistence/server** (`@tanstack/ai-persistence`) — authoritative server half +- **ai-core/chat-experience** — `useChat`, resumable connections +- Resumable streams docs — mid-stream rejoin diff --git a/packages/ai/skills/ai-core/locks/SKILL.md b/packages/ai/skills/ai-core/locks/SKILL.md new file mode 100644 index 000000000..f815cc484 --- /dev/null +++ b/packages/ai/skills/ai-core/locks/SKILL.md @@ -0,0 +1,143 @@ +--- +name: ai-core/locks +description: > + LockStore, InMemoryLockStore, LocksCapability and withLocks for + multi-instance coordination in TanStack AI. Ships in @tanstack/ai — NOT in + @tanstack/ai-persistence. Separate from AIPersistence state stores — not a + stores key, not composable. InMemoryLockStore vs a distributed (e.g. + Cloudflare Durable Object) lock, lease recovery, AbortSignal in critical + sections. Use when sandbox or other middleware needs cross-worker mutual + exclusion — NOT for storing messages/runs (use withPersistence). +type: sub-skill +library: tanstack-ai +library_version: '0.10.0' +sources: + - 'TanStack/ai:docs/advanced/locks.md' + - 'TanStack/ai:packages/ai/src/activities/chat/middleware/locks.ts' +--- + +# Locks (coordination — not persistence) + +> **Dependency note:** This skill builds on ai-core and ai-core/middleware. +> `withLocks` is a ChatMiddleware that provides a capability. Locks are **not** +> part of `AIPersistence.stores` and are **not** composed with +> `composePersistence` — they ship in `@tanstack/ai`, independent of +> `@tanstack/ai-persistence`. + +## Why separate? + +State stores answer "what is durable chat data?" +Locks answer "who may run this critical section right now?" + +`withPersistence` does **not** automatically lock a whole turn. Take a +per-thread (or other) lock yourself when multi-writer races matter. + +## Wire locks + +```ts +import { withLocks, InMemoryLockStore } from '@tanstack/ai/locks' + +middleware: [ + withLocks(new InMemoryLockStore()), // single process +] +``` + +Alongside persistence — optional, locks do not require it: + +```ts +import { withLocks, InMemoryLockStore } from '@tanstack/ai/locks' +import { withPersistence } from '@tanstack/ai-persistence' + +middleware: [withPersistence(persistence), withLocks(new InMemoryLockStore())] +``` + +`withLocks` provides `LocksCapability` for downstream middleware (e.g. +sandbox). Order: usually state first, locks alongside or after depending on +who consumes the capability. + +## The contract + +```ts +interface LockStore { + withLock(key: string, fn: (signal: AbortSignal) => Promise): Promise +} +``` + +`InMemoryLockStore` ships in **`@tanstack/ai/locks`**: a per-key promise chain, +correct **within a single process only**. Multi-instance deployments need a +distributed implementation — you write it. The Cloudflare Durable Object recipe +is in **ai-persistence/build-cloudflare-adapter** (`@tanstack/ai-persistence`). + +Type your own store with `defineLock` (autocomplete, no `: LockStore` +annotation), then hand it to `withLocks`. Acquire the key, run `fn`, release when +`fn` settles: + +```ts +import { defineLock, withLocks } from '@tanstack/ai/locks' +import { acquire } from './my-lock-backend' + +const locks = defineLock({ + async withLock(key, fn) { + const { release, signal } = await acquire(key) + try { + return await fn(signal) + } finally { + release() + } + }, +}) + +middleware: [withLocks(locks)] +``` + +## Lease semantics + +A good `LockStore`: + +- Serializes owners per key, +- Uses **leases** (or equivalent) so a crashed owner cannot block forever, +- Passes an `AbortSignal` into the critical section via `withLock`; when the + lease is lost, abort so work stops starting external mutations. + +Callbacks must honor the signal and pass it to cancellable dependencies. +`InMemoryLockStore` never aborts its signal — within one process, ownership +cannot be lost. + +## Capability identity + +The `'locks'` capability token lives in `@tanstack/ai/locks`. Capability identity +is by **object reference**, so one shared token means a `withLocks` in the chain +reaches `withSandbox` automatically. + +## Common mistakes + +### HIGH: Importing locks from `@tanstack/ai-persistence` + +They are not exported there. Use `@tanstack/ai`. + +### HIGH: Putting `locks` on `AIPersistence.stores` + +Not supported. `stores` accepts only `messages`, `runs`, `interrupts`, +`metadata` — never `locks`. Use `withLocks`. + +### HIGH: Passing `locks` to `composePersistence` overrides + +Same rejection, at the override layer. Locks are not state. + +### HIGH: Passing `'locks'` to the conformance testkit's `skip` + +`skip` accepts only chat state store keys. The suite does not cover locks +at all — test lease expiry and abort separately. + +### HIGH: `InMemoryLockStore` across multiple processes + +No mutual exclusion between machines — use a distributed lock store. + +### MEDIUM: Ignoring lease abort + +Continuing work after losing the lease races other owners. + +## Cross-references + +- See also: **ai-core/middleware/SKILL.md** -- the middleware chain and capability plumbing +- See also: **`@tanstack/ai-persistence` skills** (`skills/ai-persistence/SKILL.md` in that package) -- `ai-persistence/server` (state middleware) and `ai-persistence/build-cloudflare-adapter` (Durable Object lock recipe) diff --git a/packages/ai/skills/ai-core/middleware/SKILL.md b/packages/ai/skills/ai-core/middleware/SKILL.md index 65c6bf7b8..01a3ca4c6 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,97 @@ 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, memoryPersistence } from '@tanstack/ai-persistence' + +// memoryPersistence() is the in-process reference backend (dev/tests). For a +// durable one, implement the store contracts against your database — see the +// @tanstack/ai-persistence skills. +const persistence = memoryPersistence() + +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 + +`@tanstack/ai-persistence` ships **contracts, not a database backend**. It +provides the four store interfaces (`messages`, `runs`, `interrupts`, +`metadata`), the middleware that drives them, `memoryPersistence()` for +dev/tests, and a conformance testkit. For anything durable you implement the +stores against your own database and pass the result to `withPersistence`. + +Annotate your factory with a named shape (`ChatPersistence` / +`ChatTranscriptPersistence`) — bare `AIPersistence` is the all-optional bag and +`withPersistence` rejects it. + +Locks are separate from state and are **not** a `stores` key: wire a +`LockStore` with `withLocks(lockStore)`. + +**Full guidance lives in the package's own skills** — start at +`node_modules/@tanstack/ai-persistence/skills/ai-persistence/SKILL.md`, +which routes to the server, client, stores, locks, and adapter-recipe +(Drizzle / Prisma / Cloudflare) sub-skills. + +### Resume reconstruction is the middleware's job (server-authoritative path) + +When a thread has pending interrupts, the middleware **records** them and +**gates** new input: a request that carries pending interrupts must include a +`resume` batch that references them, or `onConfig` throws. On a valid resume +batch the middleware also **builds `ChatResumeToolState`** (approvals / +client-tool results) and **clears `config.resume`** so the chat engine skips +its ephemeral reconstruction — that path needs client message history the +persistence flow deliberately omits when the server owns the transcript. +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 +649,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: **`@tanstack/ai-persistence` skills** (`skills/ai-persistence/SKILL.md` in that package) -- Full persistence suite (`withPersistence`, client storage, store contracts, adapter recipes, locks). This file only sketches server `withPersistence`. 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/activities/chat/middleware/index.ts b/packages/ai/src/activities/chat/middleware/index.ts index ad4fa9dad..80fbb3961 100644 --- a/packages/ai/src/activities/chat/middleware/index.ts +++ b/packages/ai/src/activities/chat/middleware/index.ts @@ -41,3 +41,13 @@ export type { } from './builder' export { validateCapabilities } from './validate' export type { AnyChatMiddleware } from './types' + +export { + LocksCapability, + getLocks, + provideLocks, + InMemoryLockStore, + withLocks, + defineLock, +} from './locks' +export type { LockStore } from './locks' diff --git a/packages/ai/src/activities/chat/middleware/locks.ts b/packages/ai/src/activities/chat/middleware/locks.ts new file mode 100644 index 000000000..a4f85af4a --- /dev/null +++ b/packages/ai/src/activities/chat/middleware/locks.ts @@ -0,0 +1,102 @@ +/** + * Distributed-mutex primitive — the neutral home for the `'locks'` capability. + * + * Capability identity is by object reference (see `createCapability`). Any + * middleware may PROVIDE a {@link LockStore} via {@link withLocks} / + * {@link provideLocks}; consumers (notably `@tanstack/ai-sandbox` `ensure`) + * read it with {@link getLocks}. Coordination, not state persistence. + */ +import { createCapability } from './capabilities' +import { defineChatMiddleware } from './define' +import type { ChatMiddleware, ChatMiddlewareContext } from './types' + +/** + * Mutual exclusion around a critical section keyed by `key`. A distributed + * backend (e.g. a Cloudflare Durable Object) is the only kind 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. Callbacks that ignore `signal` (e.g. the sandbox `ensure` critical + * section) remain valid — a `() => Promise` is assignable to the + * signal-taking parameter. + */ +export interface LockStore { + withLock: ( + key: string, + fn: (signal: AbortSignal) => Promise, + ) => Promise +} + +/** + * Type a {@link LockStore} implementation inline: pass the object and get + * autocomplete + contract checking, with no separate `: LockStore` annotation. + * Hand the result to {@link withLocks}. + */ +export function defineLock(lock: LockStore): LockStore { + return lock +} + +/** + * The lock capability. Provided by {@link withLocks} or any middleware that + * calls {@link provideLocks}. + */ +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 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) + // Swallow rejections so one failure doesn't poison the lock, then drop the + // chain entry once this tail is still the latest — otherwise long-lived + // processes accumulate settled promises for every distinct key forever. + const settled = run.then( + () => undefined, + () => undefined, + ) + this.chains.set(key, settled) + void settled.then(() => { + if (this.chains.get(key) === settled) { + this.chains.delete(key) + } + }) + return run + } +} + +/** + * Provide a {@link LockStore} on the chat middleware capability bus. + * + * Coordination only — independent of chat state persistence. A lock provided + * here reaches any later middleware that reads {@link LocksCapability} + * (including `withSandbox`). + * + * ```ts + * middleware: [ + * withLocks(distributedLocks), + * withSandbox(sandbox), + * ] + * ``` + */ +export function withLocks(locks: LockStore): ChatMiddleware { + return defineChatMiddleware({ + name: 'locks', + provides: [LocksCapability], + setup(ctx: ChatMiddlewareContext) { + provideLocks(ctx, locks) + }, + }) +} diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 4448c4e44..c903e3e94 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -218,6 +218,8 @@ export type { DefinedChatMiddleware, AnyChatMiddleware, } from './activities/chat/middleware/index' +// Locks are a distributed-mutex primitive — coordination, not chat state — and +// live behind their own subpath: `@tanstack/ai/locks` (see ./locks.ts). // Well-known AG-UI CUSTOM event catalog (agent activity rides on CUSTOM events) export { CUSTOM_EVENT, isCustomEvent } from './custom-events' diff --git a/packages/ai/src/locks.ts b/packages/ai/src/locks.ts new file mode 100644 index 000000000..138e43917 --- /dev/null +++ b/packages/ai/src/locks.ts @@ -0,0 +1,17 @@ +/** + * Distributed-mutex primitive — coordination, not state persistence. + * + * Its own subpath (`@tanstack/ai/locks`) so it stays out of the main barrel and + * consumers opt in explicitly. Provide a store with `withLocks`; downstream + * middleware (e.g. `@tanstack/ai-sandbox`'s `ensure`) reads it via `getLocks` / + * `LocksCapability`. Import from here, not from `@tanstack/ai-persistence`. + */ +export { + LocksCapability, + getLocks, + provideLocks, + InMemoryLockStore, + withLocks, + defineLock, +} from './activities/chat/middleware/index' +export type { LockStore } from './activities/chat/middleware/index' diff --git a/packages/ai/src/stream-durability.ts b/packages/ai/src/stream-durability.ts index 3a7d29ae6..f2f35573a 100644 --- a/packages/ai/src/stream-durability.ts +++ b/packages/ai/src/stream-durability.ts @@ -117,10 +117,6 @@ function memoryThreshold(offset: string, runId: string, tail: number): number { return decoded.seq } -function isTerminalChunk(chunk: StreamChunk): boolean { - return chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR' -} - interface MemoryEntry { seq: number offset: string @@ -150,14 +146,22 @@ const COMPLETED_LOG_TTL_MS = 5 * 60_000 * How long a from-start join (`-1` / `now`) waits for a run's first chunk before * failing. Bounds the "joined a run that never produces" case so a consumer * gets a surfaced error instead of an indefinitely-open, event-less connection. + * + * Defaults short: the common from-start join is a reload rejoining a run whose + * producer ran in a PRIOR request, so an in-flight run's log already holds + * chunks (it streams immediately, deadline never applies) and an empty log means + * the run is gone — failing fast lets the client re-enable input near-instantly + * instead of hanging. Raise `firstChunkDeadlineMs` for backends where a producer + * legitimately starts well after a joiner attaches (a queued/deferred job). */ -const DEFAULT_FIRST_CHUNK_DEADLINE_MS = 30_000 +const DEFAULT_FIRST_CHUNK_DEADLINE_MS = 100 /** Options for the in-process delivery-durability backend. */ export interface MemoryStreamOptions { /** * Milliseconds a from-start join waits for the run's first chunk before - * throwing. Defaults to {@link DEFAULT_FIRST_CHUNK_DEADLINE_MS}. + * throwing. Defaults to {@link DEFAULT_FIRST_CHUNK_DEADLINE_MS} (100ms) — + * raise it if a producer can legitimately start long after a joiner attaches. */ firstChunkDeadlineMs?: number } @@ -236,7 +240,6 @@ export function memoryStream( const seq = firstSeq + index const offset = encodeMemoryOffset(runId, seq) log.entries.push({ seq, offset, chunk }) - if (isTerminalChunk(chunk)) markComplete(log) return offset }) wakeWaiters(log) @@ -284,9 +287,14 @@ export function memoryStream( index += 1 if (entry && entry.seq > threshold) { yield { offset: entry.offset, chunk: entry.chunk } - if (isTerminalChunk(entry.chunk)) return } } + // A terminal chunk (RUN_FINISHED / RUN_ERROR) does NOT end the read: an + // agent-loop run emits one per iteration (finishReason "tool_calls" then + // "stop"), so stopping on the first would truncate a tool-calling run at + // its first tool call. The producer signals true completion by calling + // `close()` (it does so on every exit — see StreamDurability.close), which + // sets `log.complete`. Read tails until then, or until the caller aborts. if (log.complete || signal?.aborted) return // Bound only the wait for the very first chunk: once a run has produced 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/packages/ai/tests/in-memory-lock-store.test.ts b/packages/ai/tests/in-memory-lock-store.test.ts new file mode 100644 index 000000000..3b588745c --- /dev/null +++ b/packages/ai/tests/in-memory-lock-store.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { InMemoryLockStore, defineLock } from '../src/locks' + +describe('InMemoryLockStore', () => { + it('serializes concurrent withLock calls on the same key', async () => { + const locks = new InMemoryLockStore() + const order: Array = [] + await Promise.all([ + locks.withLock('k', async () => { + order.push(1) + await Promise.resolve() + order.push(2) + }), + locks.withLock('k', async () => { + order.push(3) + }), + ]) + expect(order).toEqual([1, 2, 3]) + }) + + it('releases the lock when the critical section throws', async () => { + const locks = new InMemoryLockStore() + await expect( + locks.withLock('throw-key', () => Promise.reject(new Error('boom'))), + ).rejects.toThrow('boom') + + const result = await locks.withLock('throw-key', () => + Promise.resolve('recovered'), + ) + expect(result).toBe('recovered') + }) + + it('returns the critical section value', async () => { + const locks = new InMemoryLockStore() + const result = await locks.withLock('v', () => Promise.resolve(42)) + expect(result).toBe(42) + }) +}) + +describe('defineLock', () => { + it('types and returns a LockStore implementation inline', async () => { + const base = new InMemoryLockStore() + const locks = defineLock({ + withLock: (key, fn) => base.withLock(key, fn), + }) + const result = await locks.withLock('k', async () => 'done') + expect(result).toBe('done') + }) +}) diff --git a/packages/ai/tests/stream-durability.test.ts b/packages/ai/tests/stream-durability.test.ts index 03cacb470..00016e267 100644 --- a/packages/ai/tests/stream-durability.test.ts +++ b/packages/ai/tests/stream-durability.test.ts @@ -165,10 +165,57 @@ describe('memoryStream', () => { await producer.append([ev.textContent('c'), ev.textContent('d')]) await producer.append([ev.runFinished()]) + // A terminal chunk no longer ends the read; the producer signals completion + // by closing (an agent-loop run emits a RUN_FINISHED per iteration). + await producer.close() await done expect(received).toEqual(['a', 'b', 'c', 'd', '[RUN_FINISHED]']) }) + it('tails an agent-loop run across per-iteration terminals to close', async () => { + // A tool-calling run emits RUN_STARTED/RUN_FINISHED PER iteration. The reader + // must not stop on the first RUN_FINISHED (finishReason "tool_calls") — it + // must deliver the tool result and the second iteration's reply, ending only + // when the producer closes. + const producer = memoryStream( + new Request('https://example.test/api/chat?runId=run-agentloop', { + method: 'POST', + }), + ) + const joiner = memoryStream( + new Request( + 'https://example.test/api/chat?runId=run-agentloop&offset=-1', + { method: 'POST' }, + ), + ) + const resumeOffset = joiner.resumeFrom() + if (resumeOffset === null) throw new Error('Expected a resume offset') + const received: Array = [] + const done = (async () => { + for await (const { chunk } of joiner.read(resumeOffset)) { + received.push(label(chunk)) + } + })() + + // Iteration 1: a tool call, then a per-iteration terminal. + await producer.append([ev.textContent('rolling'), ev.runFinished()]) + await new Promise((resolve) => setTimeout(resolve, 10)) + // The first terminal must NOT have ended the reader. + expect(received).toEqual(['rolling', '[RUN_FINISHED]']) + + // Iteration 2: the tool result feeds back and the model replies, then the + // producer closes. + await producer.append([ev.textContent('you rolled a 14'), ev.runFinished()]) + await producer.close() + await done + expect(received).toEqual([ + 'rolling', + '[RUN_FINISHED]', + 'you rolled a 14', + '[RUN_FINISHED]', + ]) + }) + it('supports an adapter-owned tail sentinel for future writes', async () => { const producer = memoryStream( new Request('https://example.test/api/chat?runId=run-tail', { @@ -192,6 +239,7 @@ describe('memoryStream', () => { await new Promise((resolve) => setTimeout(resolve, 10)) await producer.append([ev.textContent('new'), ev.runFinished()]) + await producer.close() await done expect(received).toEqual(['new', '[RUN_FINISHED]']) }) @@ -229,6 +277,24 @@ describe('memoryStream', () => { ) }) + it('defaults the first-chunk deadline to a short 100ms window', async () => { + // A reload rejoin is the common from-start join; its producer ran in a prior + // request, so an empty log means the run is gone and should fail fast rather + // than hang. The default is short so the client re-enables near-instantly. + const joiner = memoryStream( + new Request( + 'https://example.test/api/chat?runId=run-default-deadline&offset=-1', + { method: 'POST' }, + ), + ) + const resumeOffset = joiner.resumeFrom() + if (resumeOffset === null) throw new Error('Expected a resume offset') + + await expect(readLabels(joiner.read(resumeOffset))).rejects.toThrow( + /produced no data within 100ms/, + ) + }) + it('does not apply the first-chunk deadline once a run has produced data', async () => { const producer = memoryStream( new Request('https://example.test/api/chat?runId=run-slow-tail', { @@ -262,6 +328,7 @@ describe('memoryStream', () => { expect(received).toEqual(['a']) await producer.append([ev.textContent('b'), ev.runFinished()]) + await producer.close() await done expect(received).toEqual(['a', 'b', '[RUN_FINISHED]']) }) diff --git a/packages/ai/vite.config.ts b/packages/ai/vite.config.ts index 5189bd6eb..da392a889 100644 --- a/packages/ai/vite.config.ts +++ b/packages/ai/vite.config.ts @@ -32,6 +32,7 @@ export default mergeConfig( entry: [ './src/index.ts', './src/client.ts', + './src/locks.ts', './src/activities/index.ts', './src/middlewares/index.ts', './src/middlewares/otel.ts', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45e6aa0f4..caec892a8 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(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(chokidar@5.0.0)(dotenv@17.2.3)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(mysql2@3.15.3)(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(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(chokidar@5.0.0)(dotenv@17.2.3)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(mysql2@3.15.3)(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(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(chokidar@5.0.0)(dotenv@17.2.3)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(mysql2@3.15.3)(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,9 @@ importers: '@tanstack/ai-openrouter': specifier: workspace:* version: link:../../packages/ai-openrouter + '@tanstack/ai-persistence': + specifier: workspace:* + version: link:../../packages/ai-persistence '@tanstack/ai-react': specifier: workspace:* version: link:../../packages/ai-react @@ -798,7 +801,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(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(chokidar@5.0.0)(dotenv@17.2.3)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(mysql2@3.15.3)(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 +904,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(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(chokidar@5.0.0)(dotenv@17.2.3)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(mysql2@3.15.3)(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 +1038,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(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(chokidar@5.0.0)(dotenv@17.2.3)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(mysql2@3.15.3)(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 +1135,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(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.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)) @@ -2071,6 +2074,18 @@ 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-preact: dependencies: '@tanstack/ai-client': @@ -2638,7 +2653,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(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.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 @@ -2750,7 +2765,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(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.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 @@ -2765,7 +2780,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(ad5aa143b5402ea1fab6b2d0538871c6) highlight.js: specifier: ^11.11.1 version: 11.11.1 @@ -3957,6 +3972,20 @@ packages: '@deno/shim-deno@0.19.2': resolution: {integrity: sha512-q3VTHl44ad8T2Tw2SpeAvghdGOjlnLPDNO2cpOxwMrBE/PVas6geWpbpIgrM+czOCH0yejp0yi8OaTuB+NU40Q==} + '@electric-sql/pglite-socket@0.1.3': + resolution: {integrity: sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==} + hasBin: true + peerDependencies: + '@electric-sql/pglite': 0.4.3 + + '@electric-sql/pglite-tools@0.3.3': + resolution: {integrity: sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg==} + peerDependencies: + '@electric-sql/pglite': 0.4.3 + + '@electric-sql/pglite@0.4.3': + resolution: {integrity: sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ==} + '@elevenlabs/client@1.3.1': resolution: {integrity: sha512-bQUxA/X7TZRSSZ6UM6a6A+1qQy5Wh7vMn+zbZP6Yl1WrupxHL4M0XMnl/n9+fsol1Ib4tN/2Nhx1E5JDS7QdKw==} @@ -6711,6 +6740,63 @@ packages: '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@prisma/client-runtime-utils@7.9.0': + resolution: {integrity: sha512-kMVmS4ZEy3xlkca+TfxOEm/ToVVlOS2x1Tc6/wIRf/HfczBqENtSPcKszy4ZpFNzjJ8SRKvlU5V0rrpoFw2KOg==} + + '@prisma/client@7.9.0': + resolution: {integrity: sha512-BTG/mB+WL/1sD2gWwdNc2uuVJjNNBgCDlPFdjco6jJArgbg4IAChtzVeW4debFa/NKBbsGedCjET316sjllWTQ==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + peerDependencies: + prisma: '*' + typescript: '>=5.4.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@7.9.0': + resolution: {integrity: sha512-CsoK2mhl0u+N4/8V+XroQMOUNIic4isqD+E2HBG8l1yGEKo62CFDu3FHo0FdwItjl6XkW+omA1STSzeN1DAXlg==} + + '@prisma/debug@7.2.0': + resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==} + + '@prisma/debug@7.9.0': + resolution: {integrity: sha512-i0KdVQuKUE6N9NloHs+sUNAk2c9svR3myBndQbA3BoeoArsSpwtNgTdHZL+wBtCLCcdS2OOC/PKhgTe36jkF5A==} + + '@prisma/dev@0.24.14': + resolution: {integrity: sha512-NhFO49O2JPTdzYiLHvceQn/HiwmcKF/iGV39ko3CpYsoGqS3rz3ko6gzuxFSIeHNwNJeuNcDexyyGeTO3DW80A==} + + '@prisma/engines-version@7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad': + resolution: {integrity: sha512-2BsPPFksz3CQUXG6af3rVCtJKg6+JJGJTtfgu2fU8DdXhOfkBjulCq8mwybCd6ge0/jhZq2kOtLAbmUDMyI1nA==} + + '@prisma/engines@7.9.0': + resolution: {integrity: sha512-lDWJp/pgSWCLfYsupmmNo96jfsbQnH1yjia8XVM2Kh8nRZhD0bQU2jCHuy3ZTPMLR3apRD3k145ybENalAYjYw==} + + '@prisma/fetch-engine@7.9.0': + resolution: {integrity: sha512-F0XlIgjbE3EywRVR/HpCerNI/dxo40vK66tHcWpsWYwH/Jk9+FsICEzATeMsZ7bdnpZz93hkD4sAb5rKLsCCpA==} + + '@prisma/get-platform@7.2.0': + resolution: {integrity: sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==} + + '@prisma/get-platform@7.9.0': + resolution: {integrity: sha512-4awv6ATdgrHdLms0XKikCyfArn8BrUHZfqg0mtCKrI4+WJe24nmpsdwsypM9ozd03wa846AngY+zSbnngkMrXQ==} + + '@prisma/query-plan-executor@7.2.0': + resolution: {integrity: sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==} + + '@prisma/streams-local@0.1.11': + resolution: {integrity: sha512-0TcebL559MByKqTJ+SsrFIEg228iw8UCVRFckzgfRSiJqczhs+MuAgWOF9lnOIV/IVqvu+KMnFTH0eDeTQMpUg==} + engines: {bun: '>=1.2.0', node: '>=22.0.0'} + + '@prisma/studio-core@0.33.0': + resolution: {integrity: sha512-V2fX/nKEymNTrHXwfP26PGjoLStO35Ogu+ex7CFJbLrMYEcZxxZpiSNOs7px23Hk5mzLWvM5RsqG6Ka+rha+wg==} + engines: {node: ^20.19 || ^22.12 || >=24.0, pnpm: '8'} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -8743,10 +8829,6 @@ packages: resolution: {integrity: sha512-fR1GGpp6v3dVKu4KIAjEh+Sd0qGLQd/wvCOVHeopSY6aFidXKCzwrS5cBOBqoPPWTKmn6CdW1a0CzFr5Furdog==} engines: {node: '>=12'} - '@tanstack/router-core@1.157.16': - resolution: {integrity: sha512-eJuVgM7KZYTTr4uPorbUzUflmljMVcaX2g6VvhITLnHmg9SBx9RAgtQ1HmT+72mzyIbRSlQ1q0fY/m+of/fosA==} - engines: {node: '>=12'} - '@tanstack/router-core@1.159.4': resolution: {integrity: sha512-MFzPH39ijNO83qJN3pe7x4iAlhZyqgao3sJIzv3SJ4Pnk12xMnzuDzIAQT/1WV6JolPQEcw0Wr4L5agF8yxoeg==} engines: {node: '>=12'} @@ -9142,27 +9224,57 @@ packages: '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + '@types/d3-array@3.0.3': + resolution: {integrity: sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + '@types/d3-color@3.1.0': + resolution: {integrity: sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==} + '@types/d3-color@3.1.3': resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + '@types/d3-delaunay@6.0.1': + resolution: {integrity: sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==} + '@types/d3-ease@3.0.2': resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + '@types/d3-format@3.0.1': + resolution: {integrity: sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-interpolate@3.0.1': + resolution: {integrity: sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==} + '@types/d3-interpolate@3.0.4': resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} '@types/d3-path@3.1.1': resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + '@types/d3-scale@4.0.2': + resolution: {integrity: sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==} + '@types/d3-scale@4.0.9': resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + '@types/d3-shape@3.1.7': + resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==} + '@types/d3-shape@3.1.8': resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + '@types/d3-time-format@2.1.0': + resolution: {integrity: sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==} + + '@types/d3-time@3.0.0': + resolution: {integrity: sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==} + '@types/d3-time@3.0.4': resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} @@ -9193,6 +9305,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -9521,6 +9636,41 @@ packages: resolution: {integrity: sha512-WSN1z931BtasZJlgPp704zJFnQFRg7yzSjkm3MzAWQYe4uXFXlFr1hc5Ac2zae5/HDOz5x1/zDM5Cb54vTCnWw==} hasBin: true + '@visx/curve@4.0.1-alpha.0': + resolution: {integrity: sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==} + + '@visx/event@4.0.1-alpha.0': + resolution: {integrity: sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g==} + + '@visx/grid@4.0.1-alpha.0': + resolution: {integrity: sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/group@4.0.1-alpha.0': + resolution: {integrity: sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/point@4.0.1-alpha.0': + resolution: {integrity: sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg==} + + '@visx/responsive@4.0.1-alpha.0': + resolution: {integrity: sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/scale@4.0.1-alpha.0': + resolution: {integrity: sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A==} + + '@visx/shape@4.0.1-alpha.0': + resolution: {integrity: sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/vendor@4.0.0-alpha.0': + resolution: {integrity: sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==} + '@vitejs/plugin-basic-ssl@2.1.4': resolution: {integrity: sha512-HXciTXN/sDBYWgeAD4V4s0DN0g72x5mlxQhHxtYu3Tt8BLa6MzcJZUyDVFCdtjNs3bfENVHVzOsmooTVuNgAAw==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -9895,6 +10045,10 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + aws4fetch@1.0.20: resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} @@ -10053,6 +10207,13 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + better-result@2.10.0: + resolution: {integrity: sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==} + + better-sqlite3@12.11.1: + resolution: {integrity: sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} @@ -10174,6 +10335,14 @@ packages: magicast: optional: true + c12@3.3.4: + resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -10300,6 +10469,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + cli-boxes@3.0.0: resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} engines: {node: '>=10'} @@ -10454,6 +10626,9 @@ packages: confbox@0.2.2: resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + config-chain@1.1.13: resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} @@ -10584,6 +10759,10 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.1: + resolution: {integrity: sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==} + engines: {node: '>=12'} + d3-array@3.2.4: resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} engines: {node: '>=12'} @@ -10592,14 +10771,26 @@ packages: resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} engines: {node: '>=12'} + d3-delaunay@6.0.2: + resolution: {integrity: sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==} + engines: {node: '>=12'} + d3-ease@3.0.1: resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} engines: {node: '>=12'} + d3-format@3.1.0: + resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} + engines: {node: '>=12'} + d3-format@3.1.2: resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} engines: {node: '>=12'} + d3-geo@3.1.0: + resolution: {integrity: sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==} + engines: {node: '>=12'} + d3-interpolate@3.0.1: resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} engines: {node: '>=12'} @@ -10716,6 +10907,10 @@ packages: decode-named-character-reference@1.2.0: resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + dedent-js@1.0.1: resolution: {integrity: sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==} @@ -10723,9 +10918,17 @@ packages: resolution: {integrity: sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==} engines: {node: '>= 0.4'} + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + 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'} @@ -10748,10 +10951,16 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + degenerator@5.0.1: resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} engines: {node: '>= 14'} + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -10862,10 +11071,106 @@ packages: resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} engines: {node: '>=12'} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + dotenv@8.6.0: resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==} engines: {node: '>=10'} + 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'} @@ -10896,6 +11201,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + effect@3.20.0: + resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==} + ejs@5.0.1: resolution: {integrity: sha512-COqBPFMxuPTPspXl2DkVYaDS3HtrD1GpzOGkNTJ1IYkifq/r9h8SVEFrjA3D9/VJGOEoMQcrlhpntcSUrM8k6A==} engines: {node: '>=0.12.18'} @@ -10904,6 +11212,9 @@ packages: electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + elkjs@0.11.1: + resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -10955,6 +11266,10 @@ packages: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + env-runner@0.1.14: resolution: {integrity: sha512-qdk5mmgFsd+zPg3r1bkZ+IbvpfUfypyDvNhMGypSMRpz7kOa/kI6SpW8fgyukuEM4Lo24M65r+1Ne0DtT7vFBA==} hasBin: true @@ -11189,6 +11504,10 @@ packages: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expand-tilde@2.0.2: resolution: {integrity: sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==} engines: {node: '>=0.10.0'} @@ -11302,6 +11621,13 @@ packages: engines: {node: '>= 10.17.0'} hasBin: true + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -11322,6 +11648,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + fast-sha256@1.3.0: resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} @@ -11403,6 +11732,10 @@ packages: resolution: {integrity: sha512-CvFd5ivA6HcSHbD+59P7CyzINHXzwhuQK8RY7CxJZtgDSAtRlHiCaQpZQ2lMR/WRyUIEmzUvL6G2AGurMfegZA==} engines: {node: '>=20'} + find-my-way@9.6.0: + resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==} + engines: {node: '>=20'} + find-up-simple@1.0.1: resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} engines: {node: '>=18'} @@ -11548,6 +11881,9 @@ packages: resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} engines: {node: '>=18'} + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + generic-pool@3.9.0: resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} engines: {node: '>= 4'} @@ -11605,6 +11941,13 @@ packages: resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} hasBin: true + giget@3.3.0: + resolution: {integrity: sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==} + hasBin: true + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -11660,6 +12003,12 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + grammex@3.1.13: + resolution: {integrity: sha512-LnPnhOBLEJEVKS8WFDVaA397L9Kq55Q9oSITJiVLHVdhAclfUkWzQv74KhvZHKL2Q09Pb1XdsrOsZ4LfTFFTEg==} + + graphmatch@1.1.1: + resolution: {integrity: sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==} + gtoken@8.0.0: resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==} engines: {node: '>=18'} @@ -12103,6 +12452,9 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + is-reference@1.2.1: resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} @@ -12728,6 +13080,10 @@ packages: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} + lru.min@1.1.4: + resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + lucide-react@0.561.0: resolution: {integrity: sha512-Y59gMY38tl4/i0qewcqohPdEbieBy7SovpBL9IFebhc2mDd8x4PZSOsiFRkpPcOq6bj1r/mjH/Rk73gSlIJP2A==} peerDependencies: @@ -13089,6 +13445,10 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + miniflare@4.20260609.0: resolution: {integrity: sha512-4ZfNh9ACDa/mKKQvTSO2vigyQS2MB7dEU02KRPle4FqL7S6nek+2Fq6WGzazZbt1OORYgb4OGVLnOCx+My2NNA==} engines: {node: '>=22.0.0'} @@ -13194,9 +13554,17 @@ packages: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} + mysql2@3.15.3: + resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} + engines: {node: '>= 8.0'} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + nan@2.27.0: resolution: {integrity: sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==} @@ -13205,6 +13573,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -13289,6 +13660,10 @@ packages: xml2js: optional: true + node-abi@3.94.0: + resolution: {integrity: sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==} + engines: {node: '>=10'} + node-addon-api@6.1.0: resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==} @@ -13709,6 +14084,9 @@ packages: perfect-debounce@2.0.0: resolution: {integrity: sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==} + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -13818,12 +14196,22 @@ packages: resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==} engines: {node: ^10 || ^12 || >=14} + postgres@3.4.7: + resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} + engines: {node: '>=12'} + preact@10.28.1: resolution: {integrity: sha512-u1/ixq/lVQI0CakKNvLDEcW5zfCjUQfZdK9qqWuIJtsezuyG6pk9TWj75GMuI/EzRSZB/VAE43sNWWZfiy8psw==} preact@10.28.2: resolution: {integrity: sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -13855,6 +14243,19 @@ packages: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + prisma@7.9.0: + resolution: {integrity: sha512-isQTJEK4pyOlAVzm6kBUDjzgdsgs0A/snpB38ycTHeOHW34qfepP+ClQltgDXqjZBnXALhEtE4duh9L3tN5fHw==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + hasBin: true + peerDependencies: + better-sqlite3: '>=9.0.0' + typescript: '>=5.4.0' + peerDependenciesMeta: + better-sqlite3: + optional: true + 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} @@ -13880,6 +14281,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + property-information@6.5.0: resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==} @@ -13940,6 +14344,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'} @@ -13993,6 +14400,13 @@ packages: rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + rc9@3.0.1: + resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + react-day-picker@9.14.0: resolution: {integrity: sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==} engines: {node: '>=18'} @@ -14213,6 +14627,9 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + remeda@2.33.4: + resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -14263,6 +14680,14 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + retry@0.13.1: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} @@ -14281,6 +14706,9 @@ packages: robot3@0.4.1: resolution: {integrity: sha512-hzjy826lrxzx8eRgv80idkf8ua1JAepRc9Efdtj03N3KNJuznQCPlyCJ7gnUmDFwZCLQjxy567mQVKmdv2BsXQ==} + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + rolldown-plugin-dts@0.18.3: resolution: {integrity: sha512-rd1LZ0Awwfyn89UndUF/HoFF4oH9a5j+2ZeuKSJYM80vmeN/p0gslYMnHTQHBEXPhUlvAlqGA3tVgXB/1qFNDg==} engines: {node: '>=20.19.0'} @@ -14375,6 +14803,10 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -14445,6 +14877,9 @@ packages: resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} engines: {node: '>= 18'} + seq-queue@0.0.5: + resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} + serialize-error@2.1.0: resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} engines: {node: '>=0.10.0'} @@ -14601,6 +15036,12 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + simple-plist@1.3.1: resolution: {integrity: sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==} @@ -14701,6 +15142,10 @@ packages: sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + sqlstring@2.3.3: + resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} + engines: {node: '>= 0.6'} + srvx@0.11.17: resolution: {integrity: sha512-43yM4luKfCJamyCMhrUeHUPOrf8TdZe7kN8s5zayZCH5OeprYqi49Aso5ZvHXR4aB+DHaRNO/diNFgZSMNG8Xw==} engines: {node: '>=20.16.0'} @@ -14820,6 +15265,10 @@ packages: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} engines: {node: '>=12'} + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -15148,6 +15597,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} @@ -15591,6 +16043,14 @@ packages: deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true + valibot@1.2.0: + resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + validate-npm-package-name@5.0.1: resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -16212,6 +16672,9 @@ packages: youch@4.1.0-beta.13: resolution: {integrity: sha512-3+AG1Xvt+R7M7PSDudhbfbwiyveW6B8PLBIwTyEC598biEYIjHhC89i6DBEvR0EZUjGY3uGSnC429HpIa2Z09g==} + zeptomatch@2.1.0: + resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} + zimmerframe@1.1.4: resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} @@ -16861,19 +17324,19 @@ snapshots: '@babel/code-frame@7.26.2': dependencies: - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 js-tokens: 4.0.0 picocolors: 1.1.1 '@babel/code-frame@7.27.1': dependencies: - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 js-tokens: 4.0.0 picocolors: 1.1.1 '@babel/code-frame@7.29.0': dependencies: - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 js-tokens: 4.0.0 picocolors: 1.1.1 @@ -17105,7 +17568,7 @@ snapshots: dependencies: '@babel/core': 7.28.5(supports-color@7.2.0) '@babel/helper-module-imports': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -17114,7 +17577,7 @@ snapshots: dependencies: '@babel/core': 7.28.5 '@babel/helper-module-imports': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -17123,7 +17586,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-module-imports': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -17298,17 +17761,12 @@ snapshots: '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.5(supports-color@7.2.0))': dependencies: '@babel/core': 7.28.5(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.0)': dependencies: @@ -17328,17 +17786,12 @@ snapshots: '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.5(supports-color@7.2.0))': dependencies: '@babel/core': 7.28.5(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-async-generator-functions@7.29.7(@babel/core@7.29.0)': dependencies: @@ -17654,9 +18107,9 @@ snapshots: '@babel/template@7.27.2': dependencies: - '@babel/code-frame': 7.27.1 - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@babel/template@7.29.7': dependencies: @@ -17680,7 +18133,7 @@ snapshots: dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 - '@babel/helper-globals': 7.28.0 + '@babel/helper-globals': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 '@babel/types': 7.29.7 @@ -17702,8 +18155,8 @@ snapshots: '@babel/types@7.28.5': dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 '@babel/types@7.29.0': dependencies: @@ -18015,7 +18468,7 @@ snapshots: '@opentelemetry/semantic-conventions': 1.41.1 axios: 1.18.1 busboy: 1.6.0 - dotenv: 17.2.3 + dotenv: 17.4.2 expand-tilde: 2.0.2 fast-glob: 3.3.3 form-data: 4.0.5 @@ -18042,6 +18495,19 @@ snapshots: '@deno/shim-deno-test': 0.5.0 which: 4.0.0 + '@electric-sql/pglite-socket@0.1.3(@electric-sql/pglite@0.4.3)': + dependencies: + '@electric-sql/pglite': 0.4.3 + optional: true + + '@electric-sql/pglite-tools@0.3.3(@electric-sql/pglite@0.4.3)': + dependencies: + '@electric-sql/pglite': 0.4.3 + optional: true + + '@electric-sql/pglite@0.4.3': + optional: true + '@elevenlabs/client@1.3.1(@types/dom-mediacapture-record@1.0.22)': dependencies: '@elevenlabs/types': 0.9.1 @@ -20427,6 +20893,113 @@ snapshots: '@poppinss/exception@1.2.3': {} + '@prisma/client-runtime-utils@7.9.0': + optional: true + + '@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)': + dependencies: + '@prisma/client-runtime-utils': 7.9.0 + optionalDependencies: + prisma: 7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) + typescript: 5.9.3 + optional: true + + '@prisma/config@7.9.0(magicast@0.5.2)': + dependencies: + c12: 3.3.4(magicast@0.5.2) + deepmerge-ts: 7.1.5 + effect: 3.20.0 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + optional: true + + '@prisma/debug@7.2.0': + optional: true + + '@prisma/debug@7.9.0': + optional: true + + '@prisma/dev@0.24.14(typescript@5.9.3)': + dependencies: + '@electric-sql/pglite': 0.4.3 + '@electric-sql/pglite-socket': 0.1.3(@electric-sql/pglite@0.4.3) + '@electric-sql/pglite-tools': 0.3.3(@electric-sql/pglite@0.4.3) + '@prisma/get-platform': 7.2.0 + '@prisma/query-plan-executor': 7.2.0 + '@prisma/streams-local': 0.1.11 + find-my-way: 9.6.0 + foreground-child: 3.3.1 + get-port-please: 3.2.0 + pathe: 2.0.3 + proper-lockfile: 4.1.2 + remeda: 2.33.4 + std-env: 3.10.0 + valibot: 1.2.0(typescript@5.9.3) + zeptomatch: 2.1.0 + transitivePeerDependencies: + - typescript + optional: true + + '@prisma/engines-version@7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad': + optional: true + + '@prisma/engines@7.9.0': + dependencies: + '@prisma/debug': 7.9.0 + '@prisma/engines-version': 7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad + '@prisma/fetch-engine': 7.9.0 + '@prisma/get-platform': 7.9.0 + optional: true + + '@prisma/fetch-engine@7.9.0': + dependencies: + '@prisma/debug': 7.9.0 + '@prisma/engines-version': 7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad + '@prisma/get-platform': 7.9.0 + optional: true + + '@prisma/get-platform@7.2.0': + dependencies: + '@prisma/debug': 7.2.0 + optional: true + + '@prisma/get-platform@7.9.0': + dependencies: + '@prisma/debug': 7.9.0 + optional: true + + '@prisma/query-plan-executor@7.2.0': + optional: true + + '@prisma/streams-local@0.1.11': + dependencies: + ajv: 8.20.0 + better-result: 2.10.0 + env-paths: 3.0.0 + proper-lockfile: 4.1.2 + optional: true + + '@prisma/studio-core@0.33.0(@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)': + dependencies: + '@radix-ui/react-toggle': 1.1.10(@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) + '@types/react': 19.2.7 + '@visx/curve': 4.0.1-alpha.0 + '@visx/event': 4.0.1-alpha.0 + '@visx/grid': 4.0.1-alpha.0(react@19.2.3) + '@visx/group': 4.0.1-alpha.0(react@19.2.3) + '@visx/responsive': 4.0.1-alpha.0(react@19.2.3) + '@visx/scale': 4.0.1-alpha.0 + '@visx/shape': 4.0.1-alpha.0(react@19.2.3) + d3-array: 3.2.4 + d3-shape: 3.2.0 + elkjs: 0.11.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + transitivePeerDependencies: + - '@types/react-dom' + optional: true + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -22263,7 +22836,7 @@ snapshots: '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 '@tanstack/router-utils': 1.131.2 - babel-dead-code-elimination: 1.0.10 + babel-dead-code-elimination: 1.0.12 tiny-invariant: 1.3.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: @@ -22276,7 +22849,7 @@ snapshots: '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 '@tanstack/router-utils': 1.141.0 - babel-dead-code-elimination: 1.0.10 + babel-dead-code-elimination: 1.0.12 pathe: 2.0.3 tiny-invariant: 1.3.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) @@ -22289,9 +22862,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(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.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(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.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(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.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(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.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: @@ -22426,9 +23035,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(acd959054103106c0a9bad82aa502122)': 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(18cacbf56349ba96e495ac9a4b1f347f) '@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) @@ -22468,11 +23077,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(89eee2ca08d25747ea346b710d88e83e)': dependencies: - '@tanstack/router-core': 1.157.16 + '@tanstack/router-core': 1.159.4 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(89eee2ca08d25747ea346b710d88e83e) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -22594,8 +23203,8 @@ snapshots: '@tanstack/history': 1.131.2 '@tanstack/store': 0.7.7 cookie-es: 1.2.2 - seroval: 1.4.0 - seroval-plugins: 1.4.0(seroval@1.4.0) + seroval: 1.5.0 + seroval-plugins: 1.5.0(seroval@1.5.0) tiny-invariant: 1.3.3 tiny-warning: 1.0.3 @@ -22609,16 +23218,6 @@ snapshots: tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/router-core@1.157.16': - dependencies: - '@tanstack/history': 1.154.14 - '@tanstack/store': 0.8.0 - cookie-es: 2.0.0 - seroval: 1.5.0 - seroval-plugins: 1.5.0(seroval@1.5.0) - tiny-invariant: 1.3.3 - tiny-warning: 1.0.3 - '@tanstack/router-core@1.159.4': dependencies: '@tanstack/history': 1.154.14 @@ -22690,7 +23289,7 @@ snapshots: '@tanstack/router-plugin@1.131.50(@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))': dependencies: '@babel/core': 7.29.0 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.0) '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.29.0) '@babel/template': 7.29.7 '@babel/traverse': 7.29.7 @@ -22699,7 +23298,7 @@ snapshots: '@tanstack/router-generator': 1.131.50 '@tanstack/router-utils': 1.131.2 '@tanstack/virtual-file-routes': 1.131.2 - babel-dead-code-elimination: 1.0.10 + babel-dead-code-elimination: 1.0.12 chokidar: 3.6.0 unplugin: 2.3.11 zod: 3.25.76 @@ -22713,7 +23312,7 @@ snapshots: '@tanstack/router-plugin@1.141.1(@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))': dependencies: '@babel/core': 7.29.0 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.0) '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.29.0) '@babel/template': 7.29.7 '@babel/traverse': 7.29.7 @@ -22735,12 +23334,12 @@ snapshots: '@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@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)))(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))': dependencies: - '@babel/core': 7.28.5 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.5) - '@babel/template': 7.27.2 - '@babel/traverse': 7.28.5 - '@babel/types': 7.29.0 + '@babel/core': 7.29.0 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.29.0) + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 '@tanstack/router-core': 1.159.4 '@tanstack/router-generator': 1.159.4 '@tanstack/router-utils': 1.158.0 @@ -22757,12 +23356,12 @@ snapshots: '@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))': dependencies: - '@babel/core': 7.28.5 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.5) - '@babel/template': 7.27.2 - '@babel/traverse': 7.28.5 - '@babel/types': 7.29.0 + '@babel/core': 7.29.0 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.29.0) + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 '@tanstack/router-core': 1.159.4 '@tanstack/router-generator': 1.159.4 '@tanstack/router-utils': 1.158.0 @@ -22814,9 +23413,9 @@ snapshots: '@tanstack/router-utils@1.158.0': dependencies: '@babel/core': 7.29.0 - '@babel/generator': 7.28.5 - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 + '@babel/generator': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 ansis: 4.2.0 babel-dead-code-elimination: 1.0.12 diff: 8.0.2 @@ -22829,7 +23428,7 @@ snapshots: dependencies: '@babel/code-frame': 7.27.1 '@babel/core': 7.29.0 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.0) '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.29.0) '@babel/template': 7.29.7 '@babel/traverse': 7.29.7 @@ -22845,7 +23444,7 @@ snapshots: dependencies: '@babel/code-frame': 7.27.1 '@babel/core': 7.29.0 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.0) '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.29.0) '@babel/template': 7.29.7 '@babel/traverse': 7.29.7 @@ -22953,11 +23552,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(4180420749541f7634d0ea935ae88706)': 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) + '@tanstack/router-core': 1.159.4 + '@tanstack/start-server-core': 1.159.4(crossws@0.4.6(srvx@0.11.17)) + vinxi: 0.5.3(89eee2ca08d25747ea346b710d88e83e) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -23029,21 +23628,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(ad5aa143b5402ea1fab6b2d0538871c6)': 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/router-generator': 1.141.1 + '@tanstack/react-start-plugin': 1.131.50(acd959054103106c0a9bad82aa502122) + '@tanstack/router-generator': 1.159.4 '@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(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.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(89eee2ca08d25747ea346b710d88e83e) 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: @@ -23097,7 +23696,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(18cacbf56349ba96e495ac9a4b1f347f)': dependencies: '@babel/code-frame': 7.26.2 '@babel/core': 7.29.0 @@ -23110,10 +23709,10 @@ snapshots: '@tanstack/start-server-core': 1.131.50 '@types/babel__code-frame': 7.0.6 '@types/babel__core': 7.20.5 - babel-dead-code-elimination: 1.0.10 + babel-dead-code-elimination: 1.0.12 cheerio: 1.1.2 h3: 1.13.0 - nitropack: 2.13.1(aws4fetch@1.0.20)(rolldown@1.1.5) + nitropack: 2.13.1(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.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) @@ -23191,7 +23790,7 @@ snapshots: dependencies: '@babel/code-frame': 7.27.1 '@babel/core': 7.29.0 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@rolldown/pluginutils': 1.0.0-beta.40 '@tanstack/router-core': 1.159.4 '@tanstack/router-generator': 1.159.4 @@ -23221,7 +23820,7 @@ snapshots: dependencies: '@babel/code-frame': 7.27.1 '@babel/core': 7.29.0 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@rolldown/pluginutils': 1.0.0-beta.40 '@tanstack/router-core': 1.159.4 '@tanstack/router-generator': 1.159.4 @@ -23298,9 +23897,9 @@ snapshots: '@tanstack/start-server-functions-handler@1.120.19(crossws@0.4.6(srvx@0.11.17))': dependencies: - '@tanstack/router-core': 1.157.16 - '@tanstack/start-client-core': 1.141.1 - '@tanstack/start-server-core': 1.141.1(crossws@0.4.6(srvx@0.11.17)) + '@tanstack/router-core': 1.159.4 + '@tanstack/start-client-core': 1.159.4 + '@tanstack/start-server-core': 1.159.4(crossws@0.4.6(srvx@0.11.17)) tiny-invariant: 1.3.3 transitivePeerDependencies: - crossws @@ -23316,8 +23915,8 @@ snapshots: '@tanstack/start-server-functions-ssr@1.120.19(crossws@0.4.6(srvx@0.11.17))(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/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-client-core': 1.141.1 - '@tanstack/start-server-core': 1.141.1(crossws@0.4.6(srvx@0.11.17)) + '@tanstack/start-client-core': 1.159.4 + '@tanstack/start-server-core': 1.159.4(crossws@0.4.6(srvx@0.11.17)) '@tanstack/start-server-functions-fetcher': 1.131.50 tiny-invariant: 1.3.3 transitivePeerDependencies: @@ -23337,13 +23936,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(ad5aa143b5402ea1fab6b2d0538871c6)': 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(89eee2ca08d25747ea346b710d88e83e) '@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(4180420749541f7634d0ea935ae88706) + '@tanstack/start-config': 1.120.20(ad5aa143b5402ea1fab6b2d0538871c6) '@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)) @@ -23532,26 +24131,64 @@ snapshots: '@types/cookie@0.6.0': {} + '@types/d3-array@3.0.3': + optional: true + '@types/d3-array@3.2.2': {} + '@types/d3-color@3.1.0': + optional: true + '@types/d3-color@3.1.3': {} + '@types/d3-delaunay@6.0.1': + optional: true + '@types/d3-ease@3.0.2': {} + '@types/d3-format@3.0.1': + optional: true + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + optional: true + + '@types/d3-interpolate@3.0.1': + dependencies: + '@types/d3-color': 3.1.3 + optional: true + '@types/d3-interpolate@3.0.4': dependencies: '@types/d3-color': 3.1.3 '@types/d3-path@3.1.1': {} + '@types/d3-scale@4.0.2': + dependencies: + '@types/d3-time': 3.0.4 + optional: true + '@types/d3-scale@4.0.9': dependencies: '@types/d3-time': 3.0.4 + '@types/d3-shape@3.1.7': + dependencies: + '@types/d3-path': 3.1.1 + optional: true + '@types/d3-shape@3.1.8': dependencies: '@types/d3-path': 3.1.1 + '@types/d3-time-format@2.1.0': + optional: true + + '@types/d3-time@3.0.0': + optional: true + '@types/d3-time@3.0.4': {} '@types/d3-timer@3.0.2': {} @@ -23583,6 +24220,9 @@ snapshots: '@types/estree@1.0.9': {} + '@types/geojson@7946.0.16': + optional: true + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -23948,6 +24588,92 @@ snapshots: untun: 0.1.3 uqr: 0.1.2 + '@visx/curve@4.0.1-alpha.0': + dependencies: + '@visx/vendor': 4.0.0-alpha.0 + optional: true + + '@visx/event@4.0.1-alpha.0': + dependencies: + '@types/react': 19.2.7 + '@visx/point': 4.0.1-alpha.0 + optional: true + + '@visx/grid@4.0.1-alpha.0(react@19.2.3)': + dependencies: + '@types/react': 19.2.7 + '@visx/curve': 4.0.1-alpha.0 + '@visx/group': 4.0.1-alpha.0(react@19.2.3) + '@visx/point': 4.0.1-alpha.0 + '@visx/scale': 4.0.1-alpha.0 + '@visx/shape': 4.0.1-alpha.0(react@19.2.3) + classnames: 2.5.1 + react: 19.2.3 + optional: true + + '@visx/group@4.0.1-alpha.0(react@19.2.3)': + dependencies: + '@types/react': 19.2.7 + classnames: 2.5.1 + react: 19.2.3 + optional: true + + '@visx/point@4.0.1-alpha.0': + optional: true + + '@visx/responsive@4.0.1-alpha.0(react@19.2.3)': + dependencies: + '@types/lodash': 4.17.24 + '@types/react': 19.2.7 + lodash: 4.17.21 + react: 19.2.3 + optional: true + + '@visx/scale@4.0.1-alpha.0': + dependencies: + '@visx/vendor': 4.0.0-alpha.0 + optional: true + + '@visx/shape@4.0.1-alpha.0(react@19.2.3)': + dependencies: + '@types/lodash': 4.17.24 + '@types/react': 19.2.7 + '@visx/curve': 4.0.1-alpha.0 + '@visx/group': 4.0.1-alpha.0(react@19.2.3) + '@visx/scale': 4.0.1-alpha.0 + '@visx/vendor': 4.0.0-alpha.0 + classnames: 2.5.1 + lodash: 4.17.21 + react: 19.2.3 + optional: true + + '@visx/vendor@4.0.0-alpha.0': + dependencies: + '@types/d3-array': 3.0.3 + '@types/d3-color': 3.1.0 + '@types/d3-delaunay': 6.0.1 + '@types/d3-format': 3.0.1 + '@types/d3-geo': 3.1.0 + '@types/d3-interpolate': 3.0.1 + '@types/d3-path': 3.1.1 + '@types/d3-scale': 4.0.2 + '@types/d3-shape': 3.1.7 + '@types/d3-time': 3.0.0 + '@types/d3-time-format': 2.1.0 + d3-array: 3.2.1 + d3-color: 3.1.0 + d3-delaunay: 6.0.2 + d3-format: 3.1.0 + d3-geo: 3.1.0 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + internmap: 2.0.3 + optional: true + '@vitejs/plugin-basic-ssl@2.1.4(vite@7.3.2(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))': dependencies: vite: 7.3.2(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) @@ -24453,6 +25179,9 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + aws-ssl-profiles@1.1.2: + optional: true + aws4fetch@1.0.20: {} axios@1.16.1(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0): @@ -24729,6 +25458,15 @@ snapshots: dependencies: is-windows: 1.0.2 + better-result@2.10.0: + optional: true + + better-sqlite3@12.11.1: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + optional: true + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 @@ -24865,7 +25603,7 @@ snapshots: chokidar: 5.0.0 confbox: 0.2.2 defu: 6.1.4 - dotenv: 17.2.3 + dotenv: 17.4.2 exsolve: 1.0.8 giget: 2.0.0 jiti: 2.7.0 @@ -24877,6 +25615,24 @@ snapshots: optionalDependencies: magicast: 0.5.2 + c12@3.3.4(magicast@0.5.2): + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.4.2 + exsolve: 1.0.8 + giget: 3.3.0 + jiti: 2.7.0 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.0 + rc9: 3.0.1 + optionalDependencies: + magicast: 0.5.2 + optional: true + cac@6.7.14: {} call-bind-apply-helpers@1.0.2: @@ -25021,6 +25777,9 @@ snapshots: dependencies: clsx: 2.1.1 + classnames@2.5.1: + optional: true + cli-boxes@3.0.0: {} cli-cursor@2.1.0: @@ -25161,6 +25920,9 @@ snapshots: confbox@0.2.2: {} + confbox@0.2.4: + optional: true + config-chain@1.1.13: dependencies: ini: 1.3.8 @@ -25287,16 +26049,34 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.1: + dependencies: + internmap: 2.0.3 + optional: true + d3-array@3.2.4: dependencies: internmap: 2.0.3 d3-color@3.1.0: {} + d3-delaunay@6.0.2: + dependencies: + delaunator: 5.1.0 + optional: true + d3-ease@3.0.1: {} + d3-format@3.1.0: + optional: true + d3-format@3.1.2: {} + d3-geo@3.1.0: + dependencies: + d3-array: 3.2.4 + optional: true + d3-interpolate@3.0.1: dependencies: d3-color: 3.1.0 @@ -25347,7 +26127,19 @@ snapshots: dayjs@1.11.19: {} - db0@0.3.4: {} + db0@0.3.4(@electric-sql/pglite@0.4.3)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3): + optionalDependencies: + '@electric-sql/pglite': 0.4.3 + better-sqlite3: 12.11.1 + drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)) + mysql2: 3.15.3 + + db0@0.3.4(@electric-sql/pglite@0.4.3)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3): + optionalDependencies: + '@electric-sql/pglite': 0.4.3 + better-sqlite3: 12.11.1 + drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)) + mysql2: 3.15.3 de-indent@1.0.2: {} @@ -25373,6 +26165,11 @@ snapshots: dependencies: character-entities: 2.0.2 + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + optional: true + dedent-js@1.0.1: {} deep-equal@2.2.3: @@ -25396,8 +26193,14 @@ snapshots: which-collection: 1.0.2 which-typed-array: 1.1.19 + deep-extend@0.6.0: + optional: true + deep-is@0.1.4: {} + deepmerge-ts@7.1.5: + optional: true + deepmerge@4.3.1: {} defaults@1.0.4: @@ -25420,12 +26223,20 @@ snapshots: defu@6.1.4: {} + defu@6.1.7: + optional: true + degenerator@5.0.1: dependencies: ast-types: 0.13.4 escodegen: 2.1.0 esprima: 4.0.1 + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + optional: true + delayed-stream@1.0.0: {} denque@2.1.0: {} @@ -25524,8 +26335,34 @@ snapshots: dotenv@17.2.3: {} + dotenv@17.4.2: {} + dotenv@8.6.0: {} + drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)): + optionalDependencies: + '@cloudflare/workers-types': 4.20260317.1 + '@electric-sql/pglite': 0.4.3 + '@opentelemetry/api': 1.9.1 + '@prisma/client': 7.9.0(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3) + better-sqlite3: 12.11.1 + mysql2: 3.15.3 + postgres: 3.4.7 + prisma: 7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) + optional: true + + drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)): + optionalDependencies: + '@cloudflare/workers-types': 4.20260317.1 + '@electric-sql/pglite': 0.4.3 + '@opentelemetry/api': 1.9.1 + '@prisma/client': 7.9.0(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3) + better-sqlite3: 12.11.1 + mysql2: 3.15.3 + postgres: 3.4.7 + prisma: 7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) + optional: true + dts-resolver@2.1.3(oxc-resolver@11.21.3): optionalDependencies: oxc-resolver: 11.21.3 @@ -25553,10 +26390,19 @@ snapshots: ee-first@1.1.1: {} + effect@3.20.0: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + optional: true + ejs@5.0.1: {} electron-to-chromium@1.5.267: {} + elkjs@0.11.1: + optional: true + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -25598,6 +26444,9 @@ snapshots: env-paths@2.2.1: {} + env-paths@3.0.0: + optional: true + env-runner@0.1.14(miniflare@4.20260617.1)(wrangler@4.103.0): dependencies: crossws: 0.4.6(srvx@0.11.17) @@ -25968,6 +26817,9 @@ snapshots: signal-exit: 4.1.0 strip-final-newline: 3.0.0 + expand-template@2.0.3: + optional: true + expand-tilde@2.0.2: dependencies: homedir-polyfill: 1.0.3 @@ -26213,6 +27065,14 @@ snapshots: transitivePeerDependencies: - supports-color + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + optional: true + + fast-decode-uri-component@1.0.1: + optional: true + fast-deep-equal@3.1.3: {} fast-equals@5.4.0: {} @@ -26231,6 +27091,11 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + optional: true + fast-sha256@1.3.0: {} fast-uri@3.1.2: {} @@ -26332,6 +27197,13 @@ snapshots: common-path-prefix: 3.0.0 pkg-dir: 8.0.0 + find-my-way@9.6.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + optional: true + find-up-simple@1.0.1: {} find-up@3.0.0: @@ -26468,6 +27340,11 @@ snapshots: transitivePeerDependencies: - supports-color + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + optional: true + generic-pool@3.9.0: {} gensync@1.0.0-beta.2: {} @@ -26531,6 +27408,12 @@ snapshots: nypm: 0.6.2 pathe: 2.0.3 + giget@3.3.0: + optional: true + + github-from-package@0.0.0: + optional: true + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -26600,6 +27483,12 @@ snapshots: graceful-fs@4.2.11: {} + grammex@3.1.13: + optional: true + + graphmatch@1.1.1: + optional: true + gtoken@8.0.0: dependencies: gaxios: 7.1.3 @@ -27088,6 +27977,9 @@ snapshots: is-promise@4.0.0: {} + is-property@1.0.2: + optional: true + is-reference@1.2.1: dependencies: '@types/estree': 1.0.9 @@ -27742,6 +28634,9 @@ snapshots: lru-cache@7.18.3: {} + lru.min@1.1.4: + optional: true + lucide-react@0.561.0(react@19.2.3): dependencies: react: 19.2.3 @@ -28398,6 +29293,9 @@ snapshots: mimic-function@5.0.1: {} + mimic-response@3.1.0: + optional: true + miniflare@4.20260609.0: dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -28513,17 +29411,38 @@ snapshots: mute-stream@2.0.0: {} + mysql2@3.15.3: + dependencies: + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.1 + long: 5.3.2 + lru.min: 1.1.4 + named-placeholders: 1.1.6 + seq-queue: 0.0.5 + sqlstring: 2.3.3 + optional: true + mz@2.7.0: dependencies: any-promise: 1.3.0 object-assign: 4.1.1 thenify-all: 1.6.0 + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.4 + optional: true + nan@2.27.0: optional: true nanoid@3.3.12: {} + napi-build-utils@2.0.0: + optional: true + natural-compare@1.4.0: {} needle@3.5.0: @@ -28574,11 +29493,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(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(chokidar@5.0.0)(dotenv@17.2.3)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(mysql2@3.15.3)(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(@electric-sql/pglite@0.4.3)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.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 @@ -28589,7 +29508,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(@electric-sql/pglite@0.4.3)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3))(ofetch@2.0.0-alpha.3) optionalDependencies: dotenv: 17.2.3 giget: 2.0.0 @@ -28628,11 +29547,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(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(chokidar@5.0.0)(dotenv@17.2.3)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(mysql2@3.15.3)(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(@electric-sql/pglite@0.4.3)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.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 @@ -28643,7 +29562,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(@electric-sql/pglite@0.4.3)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3))(ofetch@2.0.0-alpha.3) optionalDependencies: dotenv: 17.2.3 giget: 2.0.0 @@ -28682,7 +29601,109 @@ snapshots: - uploadthing - wrangler - nitropack@2.13.1(aws4fetch@1.0.20)(rolldown@1.1.5): + nitropack@2.13.1(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.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(@electric-sql/pglite@0.4.3)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.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(@electric-sql/pglite@0.4.3)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.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(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3)(rolldown@1.1.5): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 '@rollup/plugin-alias': 6.0.0(rollup@4.60.1) @@ -28703,7 +29724,7 @@ snapshots: cookie-es: 2.0.1 croner: 9.1.0 crossws: 0.3.5 - db0: 0.3.4 + db0: 0.3.4(@electric-sql/pglite@0.4.3)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3) defu: 6.1.4 destr: 2.0.5 dot-prop: 10.1.0 @@ -28749,7 +29770,7 @@ 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(@electric-sql/pglite@0.4.3)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3))(ioredis@5.9.2) untyped: 2.0.0 unwasm: 0.5.3 youch: 4.1.0-beta.13 @@ -28784,6 +29805,11 @@ snapshots: - supports-color - uploadthing + node-abi@3.94.0: + dependencies: + semver: 7.8.4 + optional: true + node-addon-api@6.1.0: optional: true @@ -29417,6 +30443,9 @@ snapshots: perfect-debounce@2.0.0: {} + perfect-debounce@2.1.0: + optional: true + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -29502,10 +30531,29 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres@3.4.7: + optional: true + preact@10.28.1: {} preact@10.28.2: {} + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.94.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + optional: true + prelude-ls@1.2.1: {} premove@4.0.0: {} @@ -29528,6 +30576,25 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3): + dependencies: + '@prisma/config': 7.9.0(magicast@0.5.2) + '@prisma/dev': 0.24.14(typescript@5.9.3) + '@prisma/engines': 7.9.0 + '@prisma/studio-core': 0.33.0(@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) + mysql2: 3.15.3 + postgres: 3.4.7 + optionalDependencies: + better-sqlite3: 12.11.1 + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - magicast + - react + - react-dom + optional: true + proc-log@4.2.0: {} process-nextick-args@2.0.1: {} @@ -29551,6 +30618,13 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + optional: true + property-information@6.5.0: {} property-information@7.1.0: {} @@ -29661,6 +30735,9 @@ snapshots: - typescript - utf-8-validate + pure-rand@6.1.0: + optional: true + qs@6.14.0: dependencies: side-channel: 1.1.0 @@ -29770,6 +30847,20 @@ snapshots: defu: 6.1.4 destr: 2.0.5 + rc9@3.0.1: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + optional: true + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + optional: true + react-day-picker@9.14.0(react@19.2.3): dependencies: '@date-fns/tz': 1.4.1 @@ -30139,6 +31230,9 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + remeda@2.33.4: + optional: true + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -30183,6 +31277,12 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 + ret@0.5.0: + optional: true + + retry@0.12.0: + optional: true + retry@0.13.1: {} reusify@1.1.0: {} @@ -30195,6 +31295,9 @@ snapshots: robot3@0.4.1: {} + robust-predicates@3.0.3: + optional: true + rolldown-plugin-dts@0.18.3(oxc-resolver@11.21.3)(rolldown@1.0.0-beta.53(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1))(typescript@5.9.3): dependencies: '@babel/generator': 7.28.5 @@ -30396,6 +31499,11 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + optional: true + safer-buffer@2.1.2: {} sass@1.101.0: @@ -30477,6 +31585,9 @@ snapshots: transitivePeerDependencies: - supports-color + seq-queue@0.0.5: + optional: true + serialize-error@2.1.0: {} serialize-javascript@6.0.2: @@ -30664,6 +31775,16 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: + optional: true + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + optional: true + simple-plist@1.3.1: dependencies: bplist-creator: 0.1.0 @@ -30771,6 +31892,9 @@ snapshots: sprintf-js@1.1.3: {} + sqlstring@2.3.3: + optional: true + srvx@0.11.17: {} srvx@0.8.16: {} @@ -30888,6 +32012,9 @@ snapshots: strip-final-newline@3.0.0: {} + strip-json-comments@2.0.1: + optional: true + strip-json-comments@3.1.1: {} strip-json-comments@5.0.3: {} @@ -31251,6 +32378,11 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + optional: true + tw-animate-css@1.4.0: {} tweetnacl@0.14.5: {} @@ -31512,7 +32644,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(@electric-sql/pglite@0.4.3)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.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(@electric-sql/pglite@0.4.3)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3) + ioredis: 5.9.2 + + unstorage@1.17.4(aws4fetch@1.0.20)(db0@0.3.4(@electric-sql/pglite@0.4.3)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3))(ioredis@5.9.2): dependencies: anymatch: 3.1.3 chokidar: 5.0.0 @@ -31524,14 +32671,14 @@ snapshots: ufo: 1.6.3 optionalDependencies: aws4fetch: 1.0.20 - db0: 0.3.4 + db0: 0.3.4(@electric-sql/pglite@0.4.3)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.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(@electric-sql/pglite@0.4.3)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3))(ofetch@2.0.0-alpha.3): optionalDependencies: aws4fetch: 1.0.20 chokidar: 5.0.0 - db0: 0.3.4 + db0: 0.3.4(@electric-sql/pglite@0.4.3)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3) ofetch: 2.0.0-alpha.3 untun@0.1.3: @@ -31596,6 +32743,11 @@ snapshots: uuid@7.0.3: {} + valibot@1.2.0(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + optional: true + validate-npm-package-name@5.0.1: {} vary@1.1.2: {} @@ -31632,10 +32784,10 @@ 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(89eee2ca08d25747ea346b710d88e83e): dependencies: '@babel/core': 7.29.0 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.0) '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.29.0) '@types/micromatch': 4.0.10 '@vinxi/listhen': 1.5.6 @@ -31654,7 +32806,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(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3)(rolldown@1.1.5) node-fetch-native: 1.6.7 path-to-regexp: 6.3.0 pathe: 1.1.2 @@ -31665,7 +32817,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(@electric-sql/pglite@0.4.3)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.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: @@ -32320,6 +33472,12 @@ snapshots: cookie-es: 2.0.1 youch-core: 0.3.3 + zeptomatch@2.1.0: + dependencies: + grammex: 3.1.13 + graphmatch: 1.1.1 + optional: true + zimmerframe@1.1.4: {} zip-stream@6.0.1: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d0616db5d..0c87a0383 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -95,3 +95,10 @@ allowBuilds: # JS fallback, so their native builds are not required. cpu-features: false ssh2: false + # Prisma and better-sqlite3 are peer-resolution artifacts only — no workspace + # package depends on them, so nothing imports the generated client or the + # native addon and their build scripts have nothing to produce for us. + '@prisma/client': false + '@prisma/engines': false + prisma: false + better-sqlite3: false diff --git a/scripts/scan-dangling-dts.mjs b/scripts/scan-dangling-dts.mjs index 66a5df03a..fc4aa2d50 100644 --- a/scripts/scan-dangling-dts.mjs +++ b/scripts/scan-dangling-dts.mjs @@ -62,6 +62,17 @@ function resolves(fromFile, specifier) { const IMPORT_RE = /(?:from|import)\s*\(?\s*['"](\.\.?\/[^'"]+)['"]/g +/** + * Strip block and line comments so import statements inside JSDoc `@example` + * fences (which the declaration emit also rewrites to `.js` specifiers) are + * not scanned as real imports. + * + * @param {string} src + */ +function stripComments(src) { + return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '') +} + const packageNames = existsSync(PACKAGES_DIR) ? readdirSync(PACKAGES_DIR).filter((name) => { try { @@ -94,7 +105,7 @@ let filesScanned = 0 for (const dist of dists) { for (const file of walkDts(dist)) { filesScanned += 1 - const src = readFileSync(file, 'utf8') + const src = stripComments(readFileSync(file, 'utf8')) IMPORT_RE.lastIndex = 0 let match while ((match = IMPORT_RE.exec(src))) { diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 657fe4e17..4abac986d 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' @@ -30,6 +31,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' @@ -71,6 +73,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', @@ -172,6 +179,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', @@ -366,6 +379,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 @@ -397,6 +411,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 @@ -424,6 +439,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 @@ -455,6 +471,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 @@ -483,6 +500,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 @@ -514,6 +532,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 @@ -543,6 +562,7 @@ export interface FileRouteTypes { | '/interrupts-test' | '/markdown-cjk' | '/middleware-test' + | '/persistence-durability' | '/tools-test' | '/$provider/$feature' | '/api/anthropic-bug-test' @@ -574,6 +594,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' @@ -601,6 +622,7 @@ export interface FileRouteTypes { | '/interrupts-test' | '/markdown-cjk' | '/middleware-test' + | '/persistence-durability' | '/tools-test' | '/$provider/$feature' | '/api/anthropic-bug-test' @@ -632,6 +654,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' @@ -659,6 +682,7 @@ export interface FileRouteTypes { | '/interrupts-test' | '/markdown-cjk' | '/middleware-test' + | '/persistence-durability' | '/tools-test' | '/$provider/$feature' | '/api/anthropic-bug-test' @@ -690,6 +714,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' @@ -718,6 +743,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 @@ -749,6 +775,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 @@ -767,6 +794,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' @@ -907,6 +941,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' @@ -1227,6 +1268,7 @@ const rootRouteChildren: RootRouteChildren = { InterruptsTestRoute: InterruptsTestRoute, MarkdownCjkRoute: MarkdownCjkRoute, MiddlewareTestRoute: MiddlewareTestRoute, + PersistenceDurabilityRoute: PersistenceDurabilityRoute, ToolsTestRoute: ToolsTestRoute, ProviderFeatureRoute: ProviderFeatureRoute, ApiAnthropicBugTestRoute: ApiAnthropicBugTestRoute, @@ -1258,6 +1300,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/$provider/$feature.tsx b/testing/e2e/src/routes/$provider/$feature.tsx index 0d9ef1105..98cf08a12 100644 --- a/testing/e2e/src/routes/$provider/$feature.tsx +++ b/testing/e2e/src/routes/$provider/$feature.tsx @@ -3,7 +3,11 @@ import { createFileRoute } from '@tanstack/react-router' import { uiMessagesToWire } from '@tanstack/ai' import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' import { clientTools } from '@tanstack/ai-client' -import type { ChatClientPersistence, UIMessage } from '@tanstack/ai-client' +import type { + ChatClientPersistence, + ChatPersistedState, + UIMessage, +} from '@tanstack/ai-client' import type { GeminiInteractionsCustomEventValue } from '@tanstack/ai-gemini/experimental' import type { Feature, Mode, Provider } from '@/lib/types' import { ALL_FEATURES, ALL_PROVIDERS } from '@/lib/types' @@ -99,8 +103,7 @@ function isStoredUIMessage(value: unknown): value is StoredUIMessage { ) } -function deserializeMessages(raw: string): Array { - const parsed: unknown = JSON.parse(raw) +function deserializeMessages(parsed: unknown): Array { if (!Array.isArray(parsed) || !parsed.every(isStoredUIMessage)) { throw new TypeError('Stored messages are invalid') } @@ -115,18 +118,37 @@ function deserializeMessages(raw: string): Array { })) } -/** Simple localStorage message adapter (no @tanstack/ai-client storage helpers). */ +/** + * `setItem` receives the combined `{ messages, resume? }` record, so `getItem` + * has to read that shape back — reading it as a bare array throws, the catch + * below swallows it, and the conversation silently fails to restore. A bare + * array is still accepted because the contract allows the legacy format. + * + * Only the transcript is returned: this page exercises message-list + * persistence, and the resume snapshot has its own harness in + * `persistence-durability.tsx` (which uses the real `localStoragePersistence`). + */ +function deserializePersistedState(raw: string): ChatPersistedState { + const parsed: unknown = JSON.parse(raw) + if (Array.isArray(parsed)) return { messages: deserializeMessages(parsed) } + if (!isRecord(parsed)) { + throw new TypeError('Stored chat state is invalid') + } + return { messages: deserializeMessages(parsed.messages) } +} + +/** Simple localStorage adapter (no @tanstack/ai-client storage helpers). */ const messagePersistence: ChatClientPersistence = { getItem(id) { try { const raw = localStorage.getItem(id) - return raw === null ? null : deserializeMessages(raw) + return raw === null ? null : deserializePersistedState(raw) } catch { return null } }, - setItem(id, messages) { - localStorage.setItem(id, serializeJson(messages)) + setItem(id, state) { + localStorage.setItem(id, serializeJson(state)) }, removeItem(id) { localStorage.removeItem(id) @@ -351,7 +373,6 @@ function ChatFeature({ queue, cancelQueued, } = useChat({ - id: chatId, threadId: chatId, ...transport, tools, diff --git a/testing/e2e/src/routes/api.durable-delivery.ts b/testing/e2e/src/routes/api.durable-delivery.ts index fb32f9519..1e74fca97 100644 --- a/testing/e2e/src/routes/api.durable-delivery.ts +++ b/testing/e2e/src/routes/api.durable-delivery.ts @@ -57,6 +57,93 @@ function fixedRun(threadId: string, runId: string): AsyncIterable { })() } +/** + * An agent-loop run: one RUN_STARTED/RUN_FINISHED pair PER iteration. The first + * terminal carries `finishReason: 'tool_calls'` (the model paused to call a + * tool); the tool result and a second iteration (the real answer) follow, ending + * on a `'stop'` terminal. This is the shape a tool-calling run takes on the + * wire, and the case that regressed: a durability sink that ended the log on the + * FIRST terminal truncated the run at the tool call. + */ +function agentLoopRun( + threadId: string, + runId: string, +): AsyncIterable { + return (async function* () { + const now = () => Date.now() + yield { + type: 'RUN_STARTED', + threadId, + runId, + timestamp: now(), + } as StreamChunk + yield { + type: 'TOOL_CALL_START', + toolCallId: 'call-1', + toolCallName: 'rollDice', + toolName: 'rollDice', + timestamp: now(), + } as StreamChunk + yield { + type: 'TOOL_CALL_ARGS', + toolCallId: 'call-1', + delta: '{"sides":20}', + timestamp: now(), + } as StreamChunk + yield { + type: 'TOOL_CALL_END', + toolCallId: 'call-1', + timestamp: now(), + } as StreamChunk + // First per-iteration terminal. A sink that stops here drops everything below. + yield { + type: 'RUN_FINISHED', + threadId, + runId, + model: 'fixed', + finishReason: 'tool_calls', + timestamp: now(), + } as StreamChunk + // The tool result and the second iteration must survive the first terminal. + yield { + type: 'TOOL_CALL_RESULT', + toolCallId: 'call-1', + content: '{"rolls":[14],"total":14}', + timestamp: now(), + } as StreamChunk + yield { + type: 'RUN_STARTED', + threadId, + runId, + timestamp: now(), + } as StreamChunk + yield { + type: 'TEXT_MESSAGE_CONTENT', + messageId: 'm2', + model: 'fixed', + delta: 'done', + content: 'done', + timestamp: now(), + } as StreamChunk + yield { + type: 'RUN_FINISHED', + threadId, + runId, + model: 'fixed', + finishReason: 'stop', + timestamp: now(), + } as StreamChunk + })() +} + +function isAgentLoop(request: Request): boolean { + try { + return new URL(request.url).searchParams.get('scenario') === 'agent-loop' + } catch { + return false + } +} + function durableRun(request: Request) { const url = new URL(request.url) const runId = url.searchParams.get('runId') ?? crypto.randomUUID() @@ -93,7 +180,9 @@ function durableResponse( durability: ReturnType, batch?: number, ): Response { - const stream = fixedRun('thread-durable', runId) + const stream = isAgentLoop(request) + ? agentLoopRun('thread-durable', runId) + : fixedRun('thread-durable', runId) const durabilityOption = { adapter: durability, ...(batch ? { batch } : {}) } return isNdjson(request) ? toHttpResponse(stream, { durability: durabilityOption }) 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..3f5cdeb55 --- /dev/null +++ b/testing/e2e/src/routes/api.persistence-durability.ts @@ -0,0 +1,232 @@ +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. + * + * Three 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). + * - `server-interrupt` — the SERVER-authoritative counterpart. The client runs + * `persistence: true` (caches nothing), so on mount it hydrates from the + * GET below, which returns a `reconstructChat`-shaped JSON carrying a pending + * interrupt. Proves a fresh client (empty `localStorage`) re-prompts the + * approval from the server alone — the path that was previously broken. + * + * 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' | 'server-interrupt' { + try { + const value = new URL(request.url).searchParams.get('scenario') + if (value === 'interrupt') return 'interrupt' + if (value === 'server-interrupt') return 'server-interrupt' + return 'text' + } catch { + return 'text' + } +} + +// The pending interrupt a server-authoritative client rehydrates from the GET +// below. It is the same BOUND generic interrupt shape the `interrupt` run ends +// on, but delivered as `reconstructChat`'s `interrupts.pending[]` payload rather +// than a live terminal — so the client restores it from the server on mount. +const SERVER_INTERRUPT_RUN_ID = 'server-interrupt-run' + +function serverInterruptReconstruction(): { + messages: [] + activeRun: null + interrupts: { runId: string; pending: Array> } +} { + return { + messages: [], + activeRun: null, + interrupts: { + runId: SERVER_INTERRUPT_RUN_ID, + pending: [ + { + 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: SERVER_INTERRUPT_RUN_ID, + generation: 0, + responseSchemaHash: digestInterruptJson( + canonicalInterruptJson(confirmSchema), + ), + }, + }, + }, + ], + }, + } +} + +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) }, + }) + }, + + // GET serves two jobs off one route, mirroring the production wiring: + // + // 1. Delivery replay — re-attach to an in-flight run by id + // (`?offset=-1&runId=…`). Detected via the durability adapter's + // `resumeFrom()`. Read-only: no producer stream is built. + // 2. Server-authoritative hydration — the `persistence: true` client's mount + // probe (a plain `?threadId=` GET, no resume cursor). Returns a + // `reconstructChat`-shaped JSON; the `server-interrupt` scenario carries + // a pending approval so a fresh client re-prompts it from the server. + GET: ({ request }) => { + const durability = memoryStream(request) + if (durability.resumeFrom() !== null) { + return resumeServerSentEventsResponse({ adapter: durability }) + } + const body = + scenarioOf(request) === 'server-interrupt' + ? serverInterruptReconstruction() + : { messages: [], activeRun: null, interrupts: null } + return new Response(JSON.stringify(body), { + headers: { + 'content-type': 'application/json', + 'cache-control': 'no-store', + }, + }) + }, + }, + }, +}) diff --git a/testing/e2e/src/routes/devtools-chat.tsx b/testing/e2e/src/routes/devtools-chat.tsx index 721210013..6495718ed 100644 --- a/testing/e2e/src/routes/devtools-chat.tsx +++ b/testing/e2e/src/routes/devtools-chat.tsx @@ -14,7 +14,7 @@ function DevtoolsChatRoute() { const { testId, aimockPort } = Route.useSearch() const [showSecondary, setShowSecondary] = useState(false) const chat = useChat({ - id: 'devtools-chat:primary', + threadId: 'devtools-chat:primary', connection: fetchServerSentEvents('/api/chat'), body: { provider: 'openai', feature: 'chat', testId, aimockPort }, devtools: { name: 'Support Chat' }, @@ -70,7 +70,7 @@ function SecondaryChat({ aimockPort?: number }) { const secondary = useChat({ - id: 'devtools-chat:secondary', + threadId: 'devtools-chat:secondary', connection: fetchServerSentEvents('/api/chat'), body: { provider: 'openai', feature: 'chat', testId, aimockPort }, devtools: { name: 'Secondary Chat' }, diff --git a/testing/e2e/src/routes/devtools-memory.tsx b/testing/e2e/src/routes/devtools-memory.tsx index 2f6c45dd7..f6c662697 100644 --- a/testing/e2e/src/routes/devtools-memory.tsx +++ b/testing/e2e/src/routes/devtools-memory.tsx @@ -12,7 +12,7 @@ export const Route = createFileRoute('/devtools-memory')({ function DevtoolsMemoryRoute() { const { testId, aimockPort } = Route.useSearch() const chat = useChat({ - id: 'devtools-memory:primary', + threadId: 'devtools-memory:primary', connection: fetchServerSentEvents('/api/devtools-memory'), body: { feature: 'chat', testId, aimockPort }, devtools: { name: 'Memory Chat' }, diff --git a/testing/e2e/src/routes/devtools-route-a.tsx b/testing/e2e/src/routes/devtools-route-a.tsx index b1bdb07f8..07aa8ab89 100644 --- a/testing/e2e/src/routes/devtools-route-a.tsx +++ b/testing/e2e/src/routes/devtools-route-a.tsx @@ -14,7 +14,7 @@ export const Route = createFileRoute('/devtools-route-a')({ function DevtoolsRouteA() { const search = Route.useSearch() const routeAChat = useChat({ - id: 'devtools-route-a:chat', + threadId: 'devtools-route-a:chat', connection: fetchServerSentEvents('/api/chat'), body: { provider: 'openai', @@ -25,7 +25,7 @@ function DevtoolsRouteA() { devtools: { name: 'Route A Chat' }, }) const routeAAux = useChat({ - id: 'devtools-route-a:auxiliary', + threadId: 'devtools-route-a:auxiliary', connection: fetchServerSentEvents('/api/chat'), body: { provider: 'openai', diff --git a/testing/e2e/src/routes/devtools-route-b.tsx b/testing/e2e/src/routes/devtools-route-b.tsx index a82b0d341..ea32787c4 100644 --- a/testing/e2e/src/routes/devtools-route-b.tsx +++ b/testing/e2e/src/routes/devtools-route-b.tsx @@ -11,7 +11,7 @@ export const Route = createFileRoute('/devtools-route-b')({ function DevtoolsRouteB() { const search = Route.useSearch() const routeBChat = useChat({ - id: 'devtools-route-b:chat', + threadId: 'devtools-route-b:chat', connection: fetchServerSentEvents('/api/chat'), body: { provider: 'openai', diff --git a/testing/e2e/src/routes/devtools-structured.tsx b/testing/e2e/src/routes/devtools-structured.tsx index ceb0dd2ba..b8cdcbcaf 100644 --- a/testing/e2e/src/routes/devtools-structured.tsx +++ b/testing/e2e/src/routes/devtools-structured.tsx @@ -16,7 +16,7 @@ function DevtoolsStructuredRoute() { const [contentDeltaCount, setContentDeltaCount] = useState(0) const [structuredObject, setStructuredObject] = useState(null) const chat = useChat({ - id: 'devtools-structured:primary', + threadId: 'devtools-structured:primary', connection: fetchServerSentEvents('/api/chat'), body: { provider: 'openai', diff --git a/testing/e2e/src/routes/devtools-tools.tsx b/testing/e2e/src/routes/devtools-tools.tsx index 6c8abfce8..ddde2c6ea 100644 --- a/testing/e2e/src/routes/devtools-tools.tsx +++ b/testing/e2e/src/routes/devtools-tools.tsx @@ -37,7 +37,7 @@ const tools = clientTools(inventoryLookupTool) function DevtoolsToolsRoute() { const { testId, aimockPort } = Route.useSearch() const chat = useChat({ - id: 'devtools-tools:primary', + threadId: 'devtools-tools:primary', connection: fetchServerSentEvents('/api/chat'), body: { provider: 'openai', feature: 'chat', testId, aimockPort }, tools, diff --git a/testing/e2e/src/routes/interrupts-test.tsx b/testing/e2e/src/routes/interrupts-test.tsx index 96e4dcb1a..183435cfa 100644 --- a/testing/e2e/src/routes/interrupts-test.tsx +++ b/testing/e2e/src/routes/interrupts-test.tsx @@ -117,7 +117,7 @@ function InterruptsTestPage() { cancelInterrupts, error, } = useChat({ - id: `interrupts-test-${scenario}`, + threadId: `interrupts-test-${scenario}`, connection: fetchServerSentEvents('/api/interrupts-test'), forwardedProps: { scenario, testId, aimockPort }, tools: clientTools, diff --git a/testing/e2e/src/routes/middleware-test.tsx b/testing/e2e/src/routes/middleware-test.tsx index ce36ed656..71ddf027b 100644 --- a/testing/e2e/src/routes/middleware-test.tsx +++ b/testing/e2e/src/routes/middleware-test.tsx @@ -91,7 +91,7 @@ function MiddlewareTestPage() { }>({ configs: [], saveCount: 0 }) const { messages, sendMessage, isLoading } = useChat({ - id: `mw-test-${scenario}-${middlewareMode}-${provider ?? 'openai'}-${model ?? 'default'}`, + threadId: `mw-test-${scenario}-${middlewareMode}-${provider ?? 'openai'}-${model ?? 'default'}`, connection: fetchServerSentEvents('/api/middleware-test'), body: { scenario, middlewareMode, testId, aimockPort, provider, model }, onFinish: () => { diff --git a/testing/e2e/src/routes/persistence-durability.tsx b/testing/e2e/src/routes/persistence-durability.tsx new file mode 100644 index 000000000..8f7aae0a7 --- /dev/null +++ b/testing/e2e/src/routes/persistence-durability.tsx @@ -0,0 +1,159 @@ +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. + * + * `?scenario=server-interrupt` is the SERVER-authoritative counterpart: the + * client runs `persistence: true` (caches nothing), so on mount it hydrates from + * the endpoint's GET, which returns a pending interrupt. Proves a fresh client + * re-prompts the approval from the server, not from `localStorage`. + */ + +// The store the client-authoritative scenarios use. `text`/`interrupt` pass it +// directly, caching the transcript to localStorage. `server-interrupt` instead +// runs `persistence: true` (no store), so the server owns the transcript and the +// client hydrates on mount. +const store = localStoragePersistence() + +const textConnection = fetchServerSentEvents('/api/persistence-durability') +const interruptConnection = fetchServerSentEvents( + '/api/persistence-durability?scenario=interrupt', +) +const serverInterruptConnection = fetchServerSentEvents( + '/api/persistence-durability?scenario=server-interrupt', +) + +export const Route = createFileRoute('/persistence-durability')({ + component: PersistenceDurabilityPage, + validateSearch: (search: Record) => ({ + scenario: + search.scenario === 'interrupt' + ? ('interrupt' as const) + : search.scenario === 'server-interrupt' + ? ('server-interrupt' as const) + : ('text' as const), + }), +}) + +function PersistenceDurabilityPage() { + const { scenario } = Route.useSearch() + const isInterrupt = scenario === 'interrupt' + const isServerInterrupt = scenario === 'server-interrupt' + const chatId = isServerInterrupt + ? 'persistence-durability-server-interrupt' + : isInterrupt + ? 'persistence-durability-interrupt' + : 'persistence-durability-text' + + const { messages, sendMessage, isLoading, interrupts } = useChat({ + // The threadId IS the hook's identity and its persistence key, so the + // localStorage record lives under `tanstack-ai:` and a reload with + // the same threadId restores it. + threadId: chatId, + connection: isServerInterrupt + ? serverInterruptConnection + : isInterrupt + ? interruptConnection + : textConnection, + persistence: isServerInterrupt ? true : store, + }) + + const [input, setInput] = useState('') + + const handleSubmit = () => { + const text = input.trim() + if (!text) return + setInput('') + void sendMessage(text) + } + + return ( +
+