diff --git a/.changeset/client-browser-refresh-durability.md b/.changeset/client-browser-refresh-durability.md new file mode 100644 index 000000000..3f65374a6 --- /dev/null +++ b/.changeset/client-browser-refresh-durability.md @@ -0,0 +1,17 @@ +--- +'@tanstack/ai-client': minor +'@tanstack/ai-react': minor +'@tanstack/ai-solid': minor +'@tanstack/ai-vue': minor +'@tanstack/ai-svelte': minor +'@tanstack/ai-angular': minor +'@tanstack/ai-preact': minor +--- + +Add browser-refresh durability to the `persistence` option. + +The client `persistence` adapter now stores one combined record per chat id, the message transcript plus a resume snapshot, so a full page reload restores the conversation, rehydrates any pending interrupt, and rejoins a run that was still streaming (via `joinRun`, when the connection is durability-backed). A bare `UIMessage[]` from an older store is still read for backward compatibility. + +The `persistence` option also accepts an object form, `{ store, messages?: boolean }`. `messages: false` caches only the tiny resume pointer (which run to rejoin, which interrupts are pending), keeping large transcripts off the client, durability rejoin and interrupt restore still work, and the server stays authoritative for history. A bare adapter is shorthand for `{ store, messages: true }`. + +New web storage adapters are exported for this: `localStoragePersistence`, `sessionStoragePersistence`, and `indexedDBPersistence` (plus `StorageUnavailableError` and the `ChatPersistedState` / `ChatStorageAdapter` / `ChatPersistenceConfig` / `ChatPersistenceOption` types). Because durability rides the existing `persistence` option, every framework integration (`react`, `solid`, `vue`, `svelte`, `angular`, `preact`) gets it with no framework-specific code. diff --git a/.changeset/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..64ac9799f --- /dev/null +++ b/.changeset/fresh-client-tail.md @@ -0,0 +1,41 @@ +--- +'@tanstack/ai-persistence': minor +'@tanstack/ai-persistence-drizzle': minor +'@tanstack/ai-persistence-prisma': 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 for + memory, drizzle/SQLite — which also covers Cloudflare D1 — and prisma). +- **`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 `messages: false` + (server-authoritative) mode the client now 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/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..a284760a0 --- /dev/null +++ b/.changeset/persistence-packages.md @@ -0,0 +1,19 @@ +--- +'@tanstack/ai-persistence': minor +'@tanstack/ai-persistence-drizzle': minor +'@tanstack/ai-persistence-prisma': minor +'@tanstack/ai-persistence-cloudflare': minor +--- + +Add server-side persistence for `chat()`: durable thread messages, run records, and interrupts. + +`withPersistence(persistence)` is a chat middleware that stores the conversation transcript, tracks each run's status, and records interrupt state so a paused run (tool approval, client-tool execution, generic interrupt) survives a server restart. Point it at any backend that implements the store contract: + +- `@tanstack/ai-persistence` — the store contracts, the `withPersistence` / `withGenerationPersistence` middleware, and an in-memory reference store (`memoryPersistence`) plus a conformance testkit. +- `@tanstack/ai-persistence-drizzle` — Drizzle-backed stores for SQLite and Postgres behind one `drizzlePersistence(db, { provider, schema })` entry (plus a `node:sqlite` convenience factory), sharing one bring-your-own-schema contract. Get the tables into your drizzle-kit journal by re-exporting the `/sqlite-schema` or `/pg-schema` subpath (stock tables, tracks upgrades) or by emitting an owned starter with the dialect-aware schema CLI (renames, extra columns, index tuning). +- `@tanstack/ai-persistence-prisma` — Prisma-backed stores with a models CLI that emits a provider-neutral schema fragment. Works with both Prisma 6 (`prisma-client-js`) and Prisma 7 (`prisma-client`): the client argument is typed structurally, so it accepts a client generated to any output path. +- `@tanstack/ai-persistence-cloudflare` — D1-backed stores (delegating to the Drizzle backend) plus a Durable-Object lock store for cross-instance locking. + +Resume reconstruction is delegated to the chat engine: persistence records interrupts and gates new input on a thread with pending interrupts, while the engine rebuilds the resume tool state from the resume batch and the interrupt bindings carried in the (server-loaded) message history. + +`reconstructChat(persistence, request)` is a server helper that returns a thread's stored messages as a JSON `Response`, so a server-authoritative client can hydrate its transcript on load from a one-line `GET` handler. diff --git a/.changeset/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/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/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..ba6aec4e9 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", @@ -109,7 +109,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 +175,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 +219,8 @@ { "label": "Overview", "to": "resumable-streams/overview", - "addedAt": "2026-07-17" + "addedAt": "2026-07-17", + "updatedAt": "2026-07-23" }, { "label": "Advanced", @@ -239,6 +234,77 @@ } ] }, + { + "label": "Persistence", + "children": [ + { + "label": "Overview", + "to": "persistence/overview", + "addedAt": "2026-07-22", + "updatedAt": "2026-07-25" + }, + { + "label": "Chat Persistence", + "to": "persistence/chat-persistence", + "addedAt": "2026-07-22", + "updatedAt": "2026-07-25" + }, + { + "label": "Client Persistence", + "to": "persistence/client-persistence", + "addedAt": "2026-07-22", + "updatedAt": "2026-07-24" + }, + { + "label": "Controls", + "to": "persistence/controls", + "addedAt": "2026-07-22", + "updatedAt": "2026-07-25" + }, + { + "label": "Custom Stores", + "to": "persistence/custom-stores", + "addedAt": "2026-07-22", + "updatedAt": "2026-07-25" + }, + { + "label": "SQL Backends", + "to": "persistence/sql-backends", + "addedAt": "2026-07-22", + "updatedAt": "2026-07-25" + }, + { + "label": "Drizzle", + "to": "persistence/drizzle", + "addedAt": "2026-07-22", + "updatedAt": "2026-07-25" + }, + { + "label": "Prisma", + "to": "persistence/prisma", + "addedAt": "2026-07-22", + "updatedAt": "2026-07-25" + }, + { + "label": "Cloudflare", + "to": "persistence/cloudflare", + "addedAt": "2026-07-22", + "updatedAt": "2026-07-25" + }, + { + "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": [ @@ -568,7 +634,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/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/chat-persistence.md b/docs/persistence/chat-persistence.md new file mode 100644 index 000000000..591242039 --- /dev/null +++ b/docs/persistence/chat-persistence.md @@ -0,0 +1,123 @@ +--- +title: Chat Persistence +id: chat-persistence +--- + +# Chat Persistence + +You want a conversation to outlive a single request: the transcript, whether +each run finished or is still waiting on an interrupt, all still there after the +process restarts. `withPersistence` is a chat middleware that writes that +state to a store you choose, so the server owns an authoritative copy of every +thread. + +## Persist state on the server + +Add the middleware to `chat()` and point it at a backend. Here it is a local +SQLite file via the Drizzle backend: + +```ts group=chat-persistence +import { + chat, + chatParamsFromRequestBody, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { withPersistence } from '@tanstack/ai-persistence' +import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite' + +// One store for the whole process. Stock defaults + runtime table bootstrap. +const persistence = sqlitePersistence({ + url: 'file:.data/chat.sqlite', +}) + +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`. + +Cross-worker locks are **not** part of this middleware — add `withLocks` when +other middleware needs multi-instance coordination. + +The `/sqlite` factory bootstraps stock tables for local development. In +production, emit a schema with `tanstack-ai-drizzle-schema` and migrate via +your own drizzle-kit journal. 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). +- Pick a backend: [Drizzle](./drizzle), [Prisma](./prisma), + [Cloudflare](./cloudflare), or [your own store](./custom-stores). +- Choose which stores to run: [Controls](./controls). diff --git a/docs/persistence/client-persistence.md b/docs/persistence/client-persistence.md new file mode 100644 index 000000000..3b5abf678 --- /dev/null +++ b/docs/persistence/client-persistence.md @@ -0,0 +1,184 @@ +--- +title: Client Persistence +id: client-persistence +--- + +# Client Persistence + +A `ChatClient` (and every framework `useChat` / `createChat`) keeps messages in +memory, so a reload or a crashed tab loses the whole conversation and any reply +that was still streaming. The `persistence` option fixes that from the browser +side: on reload it repaints the transcript, brings back a pending interrupt, and +rejoins a run that was mid-stream. + +You need this whether or not you have a server: + +- **The browser owns the chat** (SPA, offline-first, no server store). Client + persistence is the only durable copy, so it holds the full transcript. +- **The server owns the chat** (you use [Chat persistence](./chat-persistence)). + The server is the source of truth, but the client still has to come back + instantly on reload: rejoin the in-flight run, restore the pending interrupt, + and show the conversation. Client persistence caches the small resume pointer + that makes that possible and hydrates the transcript from the server. So this + page matters even when history lives on the server, that is the + `messages: false` mode below. + +## Turn it on + +Pass a storage adapter as `persistence` and give the chat a stable `threadId` so +a reload finds the same record: + +```tsx +import { + fetchServerSentEvents, + localStoragePersistence, + useChat, +} from '@tanstack/ai-react' + +function Chat() { + const { messages, sendMessage } = useChat({ + threadId: 'support-chat', + connection: fetchServerSentEvents('/api/chat'), + persistence: localStoragePersistence(), + }) + // ...render messages, call sendMessage(text) +} +``` + +`localStoragePersistence()` needs no type argument and no codec: it defaults to +the chat record shape and a JSON codec. That is the whole opt-in. + +## What a reload restores + +The client stores one record per `threadId`, the transcript plus a small resume +pointer. On the next load `useChat` reads it and: + +- **Repaints the transcript** 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 what to cache + +`persistence` takes either a bare adapter (cache everything) or +`{ store, messages }` (pull the message lever). + +### Cache everything (default): client-authoritative + +Pass the adapter directly, `persistence: localStoragePersistence()`. The +transcript and the resume pointer both live in the browser. The client owns the +history; the server, if any, mirrors it. Best when the browser is the source of +truth: single-page apps, offline-first, one device, small to moderate +conversations. + +### Resume pointer only: server-authoritative + +Pass the object form, `persistence: { store, messages: false }`, where `store` +is your adapter. Only the tiny resume pointer is cached (which run to rejoin, +which interrupts are pending). Reload durability still works, but the transcript +stays off the client and the server owns history. Best when transcripts are +large (localStorage is synchronous and quota-bound), when the same conversation +must open on another device, or when you simply do not want message content in +the browser. + +You do not fetch or seed the transcript yourself. 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. 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. + +**Client** — the store, a connection, and a stable `threadId`: + +```tsx +import { + fetchServerSentEvents, + localStoragePersistence, + useChat, +} from '@tanstack/ai-react' + +const connection = fetchServerSentEvents('/api/chat') +const persistence = { store: localStoragePersistence(), messages: false } + +function Chat({ threadId }: { threadId: string }) { + const { messages, sendMessage } = useChat({ threadId, connection, persistence }) + 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 | +| `{ store, messages: false }` | resume pointer only | server | large histories, multi-device, no transcripts in the browser | + +## Choose a storage backend + +Three adapters ship from `@tanstack/ai-client`, re-exported from every framework +package. All share the same shape; they differ in lifetime and encoding. + +| Adapter | Lifetime | Notes | Reach for it when | +| --- | --- | --- | --- | +| `localStoragePersistence` | across reloads and browser restarts | synchronous, ~5MB quota, JSON codec | the default: persist a conversation for next time | +| `sessionStoragePersistence` | one tab, cleared when it closes | same shape as localStorage | a conversation that should not outlive the tab | +| `indexedDBPersistence` | across reloads and restarts | async, structured clone (a `Date` round-trips exactly), room for large data | big transcripts, or values a JSON codec would mangle | + +```tsx +import { indexedDBPersistence } from '@tanstack/ai-react' + +const persistence = indexedDBPersistence() +``` + +Each throws only lazily, per operation, when its backing store is missing (for +example during server-side rendering), so constructing one on the server is safe. + +## Client and server are independent + +Client persistence restores what one browser rendered. Server persistence +([Chat persistence](./chat-persistence)) keeps the authoritative copy for every +user and survives a server restart. They compose: for the combination we +recommend for most apps, and why, see the +[Persistence overview](./overview#what-we-recommend). diff --git a/docs/persistence/cloudflare.md b/docs/persistence/cloudflare.md new file mode 100644 index 000000000..69f42d4e9 --- /dev/null +++ b/docs/persistence/cloudflare.md @@ -0,0 +1,219 @@ +--- +title: Cloudflare Persistence +id: cloudflare +--- + +# Cloudflare Persistence + +`@tanstack/ai-persistence-cloudflare` maps TanStack AI to Cloudflare-native +primitives. **State** and **locks** are separate: + +| Binding | What it provides | How you wire it | +| --- | --- | --- | +| D1 | State stores: `messages`, `runs`, `interrupts`, `metadata` | `cloudflarePersistence({ d1 })` (Drizzle under the hood) + `withPersistence` | +| Durable Objects | Distributed `LockStore` | `createDurableObjectLockStore` + `withLocks` | + +D1 state is a thin convenience over +`@tanstack/ai-persistence-drizzle` + `drizzle-orm/d1`. This package does **not** +ship SQL migrations — schema ownership matches every other SQLite backend (see +[Drizzle](./drizzle) and [Migrations](./migrations)). The Cloudflare-specific +value is Durable Object locks and D1 binding wiring. + +This package does not provide a stream-delivery adapter; stream re-attach / +delivery durability is a separate transport-layer feature +([Resumable Streams](../resumable-streams/overview)). + +## Configure bindings + +```jsonc +// wrangler.jsonc +{ + "d1_databases": [ + { + "binding": "AI_STATE", + "database_name": "tanstack-ai-state", + "database_id": "", + "migrations_dir": "drizzle" + } + ], + "durable_objects": { + "bindings": [ + { + "name": "AI_LOCKS", + "class_name": "CloudflareLockDurableObject" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["CloudflareLockDurableObject"] + } + ] +} +``` + +Point `migrations_dir` at **your** drizzle-kit output (or any SQL journal you +own). Re-export the lock Durable Object from your Worker entry when you use +locks: + +```ts +export { CloudflareLockDurableObject } from '@tanstack/ai-persistence-cloudflare' +``` + +## Schema and migrations (D1 = SQLite) + +Same path as Drizzle SQLite. Stock tables: + +```ts +// src/db/tanstack-ai-schema.ts +export * from '@tanstack/ai-persistence-drizzle/sqlite-schema' +``` + +Or emit an owned starter: + +```bash +pnpm exec tanstack-ai-drizzle-schema --out src/db +``` + +Generate DDL with drizzle-kit (`dialect: 'sqlite'`), put the journal where +Wrangler expects it, and apply: + +```bash +pnpm exec drizzle-kit generate +wrangler d1 migrations apply tanstack-ai-state --local +wrangler d1 migrations apply tanstack-ai-state --remote +``` + +## Create state persistence (D1) + +Zero-config stock schema (tables must already exist from your migrations): + +```ts ignore +import { cloudflarePersistence } from '@tanstack/ai-persistence-cloudflare' + +interface Env { + AI_STATE: D1Database +} + +export function createPersistence(env: Env) { + return cloudflarePersistence({ d1: env.AI_STATE }) +} +``` + +Prefer owning the schema and calling Drizzle directly when you rename tables or +add columns: + +```ts ignore +import { drizzle } from 'drizzle-orm/d1' +import { drizzlePersistence } from '@tanstack/ai-persistence-drizzle' +import { schema } from './db/tanstack-ai-schema' + +interface Env { + AI_STATE: D1Database +} + +export function createPersistence(env: Env) { + return drizzlePersistence(drizzle(env.AI_STATE, { schema }), { + provider: 'sqlite', + schema, + }) +} +``` + +## Optional: Durable Object locks + +Locks are **not** part of the state bag. Create a `LockStore` and pass it to +`withLocks`: + +```ts ignore +import { createDurableObjectLockStore } from '@tanstack/ai-persistence-cloudflare' + +interface Env { + AI_LOCKS: DurableObjectNamespace +} + +export function createLocks(env: Env) { + return createDurableObjectLockStore(env.AI_LOCKS, { + leaseDurationMs: 30_000, + retryDelayMs: 50, + }) +} +``` + +Each lock key is routed to a Durable Object, which serializes owners and uses +leases/alarms for recovery. + +## Use it with chat + +```ts ignore +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { withPersistence, withLocks } from '@tanstack/ai-persistence' +import { createDurableObjectLockStore } from '@tanstack/ai-persistence-cloudflare' +import { createPersistence } from './persistence' + +interface Env { + AI_STATE: D1Database + AI_LOCKS: DurableObjectNamespace +} + +export default { + async fetch(request: Request, env: Env) { + try { + const params = await chatParamsFromRequest(request) + const persistence = createPersistence(env) + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages: params.messages, + threadId: params.threadId, + runId: params.runId, + ...(params.resume ? { resume: params.resume } : {}), + middleware: [ + withPersistence(persistence), + // Only if other middleware needs multi-instance coordination: + withLocks(createDurableObjectLockStore(env.AI_LOCKS)), + ], + }) + + return toServerSentEventsResponse(stream) + } catch (error) { + if (error instanceof Response) return error + throw error + } + }, +} +``` + +State-only setups omit `withLocks` and the DO binding entirely. + +## Override selected stores + +Use Cloudflare D1 as the base and replace only application-owned stores: + +```ts ignore +import { composePersistence } from '@tanstack/ai-persistence' +import { createPersistence } from './persistence' +import { customInterrupts, customRuns } from './stores' + +interface Env { + AI_STATE: D1Database +} + +export function createComposedPersistence(env: Env) { + return composePersistence(createPersistence(env), { + overrides: { + interrupts: customInterrupts, + runs: customRuns, + }, + }) +} +``` + +D1 continues to own messages and metadata. Locks stay on `withLocks` if you +need them. Cross-backend transactions are not added by composition; design +retries and consistency explicitly. diff --git a/docs/persistence/controls.md b/docs/persistence/controls.md new file mode 100644 index 000000000..2e5845876 --- /dev/null +++ b/docs/persistence/controls.md @@ -0,0 +1,122 @@ +--- +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. + +Locks are a **separate** concern — 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 [Custom stores](./custom-stores) for the store contracts. + +## Locks (coordination) + +Locks are **not** state stores. They do not live on `AIPersistence` and cannot +be composed with `composePersistence`. Provide them with a separate middleware: + +```ts +import { + withPersistence, + withLocks, + InMemoryLockStore, +} from '@tanstack/ai-persistence' +import { createDurableObjectLockStore } from '@tanstack/ai-persistence-cloudflare' + +middleware: [ + withPersistence(persistence), + // Single process: + withLocks(new InMemoryLockStore()), + // Multi-instance (Cloudflare): + // withLocks(createDurableObjectLockStore(env.AI_LOCKS)), +] +``` + +`withLocks` provides `LocksCapability` for downstream middleware (e.g. sandbox). +It does not lock the whole chat turn automatically — take a per-thread lock in +your own middleware when multi-writer coordination is required. diff --git a/docs/persistence/custom-stores.md b/docs/persistence/custom-stores.md new file mode 100644 index 000000000..265c8d24b --- /dev/null +++ b/docs/persistence/custom-stores.md @@ -0,0 +1,217 @@ +--- +title: Custom Stores +id: custom-stores +--- + +# Custom Persistence Stores + +Implement custom stores when your infrastructure is not covered by the +packaged backends or when selected data must remain in an application-owned +database. + +## Define the stores you provide + +```ts +import { defineAIPersistence } from '@tanstack/ai-persistence' +import { messages, runs } from './stores' + +export const persistence = defineAIPersistence({ + stores: { messages, runs }, +}) +``` + +`defineAIPersistence` preserves the exact store keys in the type and rejects +unknown keys at runtime. Middleware behavior follows the keys that exist. + +## Store interfaces + +Each interface below is the public contract from `@tanstack/ai-persistence`. +Implement only the stores you need. + +### Messages + +```ts +import type { ModelMessage } from '@tanstack/ai' + +interface MessageStore { + loadThread(threadId: string): Promise> + saveThread( + threadId: string, + messages: Array, + ): Promise +} +``` + +`saveThread` receives the full authoritative model-message history, not a +delta. `loadThread` returns `[]` (never `null`) for a thread that was never +saved. + +### Runs + +```ts +import type { RunRecord } from '@tanstack/ai-persistence' + +interface RunStore { + createOrResume(input: { + runId: string + threadId: string + status?: 'running' | 'completed' | 'failed' | 'interrupted' + startedAt: number + }): Promise + update( + runId: string, + patch: Partial< + Pick + >, + ): Promise + get(runId: string): Promise +} +``` + +Implement `createOrResume` idempotently: a second call for an existing `runId` +returns the stored record unchanged, which is what makes resuming a run safe. +`update` against an unknown `runId` is a no-op. Retries may repeat the same run +id. + +### Interrupts + +```ts +import type { InterruptRecord } from '@tanstack/ai-persistence' + +interface InterruptStore { + create(record: Omit): Promise + resolve(interruptId: string, response?: unknown): Promise + cancel(interruptId: string): Promise + get(interruptId: string): Promise + list(threadId: string): Promise> + listPending(threadId: string): Promise> + listByRun(runId: string): Promise> + listPendingByRun(runId: string): Promise> +} +``` + +`create` accepts a record without `status`/`resolvedAt` so every interrupt is +born `'pending'`; it is insert-if-absent, so a duplicate `create` never clobbers +an already-resolved interrupt. The `list*` methods return records ordered by +`requestedAt` ascending. An `interrupts` store requires a `runs` store when used +with chat persistence. + +### Metadata + +```ts +interface MetadataStore { + get(scope: string, key: string): Promise + set(scope: string, key: string, value: unknown): Promise + delete(scope: string, key: string): Promise +} +``` + +Namespaces and value schemas are application-owned. `(scope, key)` is the +composite identity. Because a stored `null` is indistinguishable from absence at +the type level, wrap a value you must persist as `null` (e.g. `{ value: null }`). + +### Locks (separate from state stores) + +`LockStore` is **not** part of `AIPersistenceStores`. It is a coordination +primitive provided by `withLocks(lockStore)`, independent of +`withPersistence`. + +Use it to serialize work that may run on multiple workers. A lock implementation +should use leases or another recovery mechanism so a crashed owner cannot block +forever. `withLock` passes an `AbortSignal` to the critical section. Lease-backed +implementations abort that signal when ownership can no longer be guaranteed; +callbacks must stop starting external mutations and pass the signal to +cancellable dependencies. The package ships an in-process `InMemoryLockStore` +for single-process use; multi-instance apps use a distributed backend (e.g. +Cloudflare Durable Objects). + +## Example message store + +```ts +import type { MessageStore } from '@tanstack/ai-persistence' +import type { ModelMessage } from '@tanstack/ai' + +const threads = new Map>() + +export const messages: MessageStore = { + async loadThread(threadId) { + return [...(threads.get(threadId) ?? [])] + }, + async saveThread(threadId, nextMessages) { + threads.set(threadId, [...nextMessages]) + }, +} +``` + +For durable infrastructure, preserve the same semantics with database +transactions, conditional writes, or stable idempotency keys. + +## Override selected packaged stores + +```ts ignore +import { composePersistence } from '@tanstack/ai-persistence' +import { cloudflarePersistence } from '@tanstack/ai-persistence-cloudflare' +import { interrupts, runs } from './stores' + +export function createPersistence(state: D1Database) { + const base = cloudflarePersistence({ d1: state }) + return composePersistence(base, { + overrides: { interrupts, runs }, + }) +} +``` + +Only those two stores move to the custom database; D1 still owns messages and +metadata. Composition does not create a transaction across those systems; +design related writes accordingly. + +## When each store is called + +Most apps fold these interfaces into an existing customer database rather than +running a separate "chat store." Map each middleware hook to your tables so you +know which writes to co-locate or wrap in a transaction. + +### Chat middleware lifecycle (server) + +| Hook | `messages` | `runs` | `interrupts` | Notes | +| --- | --- | --- | --- | --- | +| `onConfig` (init) | `loadThread` when request `messages` is empty | `createOrResume` | `listPending` + validate resume batch; build `resumeToolState` | Non-empty request `messages` **replace** the stored thread on later saves — never send a delta | +| `onStart` | best-effort `saveThread` (pending turn) | — | — | Failure does not abort the run | +| `onChunk` (stream, optional) | throttled `saveThread` if `snapshotStreaming` | — | — | Best-effort partial assistant text | +| `onChunk` (interrupt boundary) | `saveThread` | status → `interrupted` | create each new interrupt; commit prior resumes | Hard-fail on store errors | +| `onFinish` | `saveThread(finishedTranscript)` **first** | status → `completed` | commit resumes | Transcript before run completion so a failed save does not leave a "completed" run with a missing turn | +| `onError` | — | status → `failed` | resumes left pending | Retry can re-send the same resume batch | +| `onAbort` | — | status → `interrupted` | resumes left pending | Same retry semantics as error for approvals | + +### Swimlanes (request → store) + +``` +Client POST (messages / resume) + │ + ▼ + onConfig ──► runs.createOrResume(runId, threadId) + │ interrupts.listPending(threadId) → gate / resumeToolState + │ messages.loadThread(threadId) → only if request messages empty + ▼ + onStart ──► messages.saveThread (best-effort pending turn) + │ + ├─ stream ──► messages.saveThread (optional throttled snapshots) + │ + ├─ interrupt ──► interrupts.create… + runs.update(interrupted) + │ messages.saveThread + │ commit prior resume resolve/cancel + │ + └─ finish ──► messages.saveThread(full transcript) + runs.update(completed) + commit resume resolve/cancel +``` + +### Invariants custom stores must keep + +- **`saveThread` is full overwrite**, not append. A one-message payload wipes history. +- **`createOrResume` is insert-if-absent** for the same `runId` (idempotent retries). +- **Interrupt create is insert-if-absent** (cannot clobber a resolved row back to pending). +- **Scope thread access at the app boundary** — store methods take bare `threadId`s; your route must authorize ownership before load/save (see `reconstructChat({ authorize })`). +- **Locks** are provided via `withLocks`, not the state bag. `withPersistence` + does not automatically lock the whole turn — take a per-thread lock yourself + if multi-writer is required. diff --git a/docs/persistence/drizzle.md b/docs/persistence/drizzle.md new file mode 100644 index 000000000..a9c4e9a0a --- /dev/null +++ b/docs/persistence/drizzle.md @@ -0,0 +1,233 @@ +--- +title: Persistence with Drizzle +id: drizzle +--- + +# Persistence with Drizzle + +`@tanstack/ai-persistence-drizzle` is **schema-first**. It does not ship SQL +migrations. You own the schema file (or accept stock defaults), generate DDL +with **your** drizzle-kit journal, and pass the schema into the runtime. + +> **Runnable example:** `examples/persistent-chat-drizzle` in this repo is this +> guide end to end — the persistent-chat demo (streaming, tool calls, durable +> approval interrupts) backed by SQLite with an emitted schema, committed +> drizzle-kit migrations, and `ensureTables: false`. + +Two entry points: + +- the package root accepts an already-created, migrated Drizzle database plus + a required `provider` (`'sqlite'` or `'pg'`) and matching `schema`, and is + safe to import in edge runtimes; +- `/sqlite` is a Node-only convenience factory built on `node:sqlite` with stock + defaults and optional runtime table bootstrap for local/dev. + +The `provider` discriminates the whole call: with `provider: 'sqlite'` the +compiler only accepts a SQLite Drizzle database and a SQLite schema, with +`provider: 'pg'` only a Postgres database and schema, and the runtime assert +verifies the passed tables really are that dialect. + +## Schema first + +There are two ways to get the tables into **your** drizzle-kit journal: + +### Stock tables: re-export the package schema + +If you don't need to rename or extend the tables, don't copy anything — create +a one-line module the package keeps up to date across upgrades: + +```ts +// src/db/tanstack-ai-schema.ts +export * from '@tanstack/ai-persistence-drizzle/sqlite-schema' +``` + +Add that file to your drizzle-kit `schema` paths and generate migrations as +usual. When a package upgrade adds a column or index, `drizzle-kit generate` +picks it up automatically — there is no copied file to go stale. Use +`@tanstack/ai-persistence-drizzle/pg-schema` for Postgres. + +### Custom tables: emit an owned starter + +To rename tables or columns, add app-owned columns, or tune indexes, own the +definition instead. Emit a starter schema into your project: + +```bash +pnpm exec tanstack-ai-drizzle-schema --out src/db +``` + +Add it to drizzle-kit so **your** journal owns the DDL: + +```ts ignore +import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + dialect: 'sqlite', + schema: ['./src/db/schema.ts', './src/db/tanstack-ai-schema.ts'], + out: './drizzle', +}) +``` + +```bash +pnpm exec drizzle-kit generate +pnpm exec drizzle-kit migrate +``` + +Then wire the runtime: + +```ts +import { drizzlePersistence } from '@tanstack/ai-persistence-drizzle' +import { schema } from './db/tanstack-ai-schema' +import { db } from './db' + +export const persistence = drizzlePersistence(db, { + provider: 'sqlite', + schema, +}) +``` + +Because the runtime operates on the table objects you pass, the file is yours +to shape: + +- **Rename tables and columns**, or drop the explicit column names and rely on + your drizzle `casing` configuration — the stores read database names from + your objects, so the generated SQL follows your conventions. +- **Add app-owned columns** — for example a `userId` column on `messages` to + scope threads to users. Keep added columns nullable or defaulted so the + store inserts succeed; the TanStack AI stores never read or write them. +- **Tune indexes** — the starter ships lookup indexes on + `interrupts.thread_id` and `interrupts.run_id` (the columns the stores list + by); add composite or partial indexes as your query patterns demand. +- **Keep the contract columns** with their data shapes. The + `TanstackAiSqliteSchema` type enforces the shapes at compile time, and + `drizzlePersistence` validates the tables and columns exist at construction. + +## Node SQLite convenience + +For local development without a project schema file yet: + +```ts +import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite' + +export const persistence = sqlitePersistence({ + url: 'file:.tanstack-ai/state.sqlite', +}) +``` + +This uses `createDefaultSqliteSchema()` and, by default, creates missing tables +with `CREATE TABLE IF NOT EXISTS` derived from that schema. That is a **bootstrap +convenience**, not a migration system — for production, emit the schema, migrate +with drizzle-kit, and pass your schema with `ensureTables: false`: + +```ts +import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite' +import { schema } from './db/tanstack-ai-schema' + +export const persistence = sqlitePersistence({ + url: 'file:.tanstack-ai/state.sqlite', + schema, + ensureTables: false, +}) +``` + +`url` may be `:memory:`, a filesystem path, or a `file:`-prefixed path. + +## Bring your own SQLite Drizzle database + +```ts ignore +import { drizzle } from 'drizzle-orm/d1' +import { + createDefaultSqliteSchema, + drizzlePersistence, +} from '@tanstack/ai-persistence-drizzle' + +export function createPersistence(state: D1Database) { + const schema = createDefaultSqliteSchema() + const db = drizzle(state, { schema }) + return drizzlePersistence(db, { provider: 'sqlite', schema }) +} +``` + +Prefer emitting and owning the schema in production. The root entry does not +import Node built-ins and works with Cloudflare D1 and other SQLite-compatible +Drizzle drivers. The application owns connection lifecycle and migration timing. + +## Postgres + +Postgres uses the same root entry with `provider: 'pg'`: bring your own +migrated Drizzle Postgres database (node-postgres, postgres.js, Neon, +PGlite, …) and your schema. For stock tables, re-export +`@tanstack/ai-persistence-drizzle/pg-schema` as shown above; to own the +definition, emit the Postgres starter: + +```bash +pnpm exec tanstack-ai-drizzle-schema --out src/db --dialect pg +``` + +Add it to your drizzle-kit config (`dialect: 'postgresql'`), generate and run +migrations, then wire the runtime: + +```ts ignore +import { drizzle } from 'drizzle-orm/node-postgres' +import { drizzlePersistence } from '@tanstack/ai-persistence-drizzle' +import { schema } from './db/tanstack-ai-schema' + +const db = drizzle(process.env.DATABASE_URL!) +export const persistence = drizzlePersistence(db, { provider: 'pg', schema }) +``` + +The same schema freedoms apply: rename tables and columns, lean on drizzle +`casing`, and add nullable or defaulted app-owned columns. The +`TanstackAiPgSchema` type enforces the contract's column data shapes at compile +time. For local development without migrations, `createDefaultPgSchema()` +provides the stock tables and `ensurePgTables` bootstraps them with +`CREATE TABLE IF NOT EXISTS`: + +```ts ignore +import { drizzle } from 'drizzle-orm/node-postgres' +import { pool } from './db' +import { + createDefaultPgSchema, + drizzlePersistence, + ensurePgTables, +} from '@tanstack/ai-persistence-drizzle' + +const schema = createDefaultPgSchema() +await ensurePgTables((sql) => pool.query(sql), schema) +export const persistence = drizzlePersistence(drizzle(pool), { + provider: 'pg', + schema, +}) +``` + +## Use the middleware + +```ts +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { withPersistence } from '@tanstack/ai-persistence' +import { persistence } from './persistence' + +export async function POST(request: Request) { + const params = await chatParamsFromRequest(request) + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages: params.messages, + threadId: params.threadId, + runId: params.runId, + ...(params.resume ? { resume: params.resume } : {}), + middleware: [withPersistence(persistence)], + }) + + return toServerSentEventsResponse(stream) +} +``` + +`drizzlePersistence` provides state stores only (messages, runs, interrupts, +metadata). Locks are separate: use `withLocks` with a distributed `LockStore` +(for example `createDurableObjectLockStore` from +`@tanstack/ai-persistence-cloudflare`) when multi-instance coordination is +required. diff --git a/docs/persistence/internals.md b/docs/persistence/internals.md new file mode 100644 index 000000000..49b551086 --- /dev/null +++ b/docs/persistence/internals.md @@ -0,0 +1,120 @@ +--- +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`. + +Locks are not state stores and are not composed here — use `withLocks`. + +The runtime checks are required because JavaScript, configuration loading, and +explicitly widened types can bypass static guarantees. + +## Backend ownership + +Packaged backends own resources differently: + +- Drizzle is schema-first: pass a required `schema` (emit via CLI; your + drizzle-kit owns migrations). The root import is edge-safe. The `/sqlite` + entry creates a Node SQLite connection with stock defaults. +- Prisma accepts the application's generated and migrated client. +- Cloudflare is a thin D1 convenience over Drizzle SQLite (`drizzle-orm/d1`); + schema DDL is owned the same way as Drizzle. Durable Object locks are a + separate export (`createDurableObjectLockStore` + `withLocks`). + +`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..4118a66f0 --- /dev/null +++ b/docs/persistence/migrations.md @@ -0,0 +1,105 @@ +--- +title: Persistence Migrations +id: migrations +--- + +# Persistence Migrations + +Migration ownership depends on the backend. Apply schema changes before +deploying code that reads or writes the corresponding stores. + +## Drizzle SQLite + +The Drizzle package is **schema-first** and does **not** ship SQL migrations. +Own the schema in your project, then generate DDL with drizzle-kit: + +```bash +pnpm exec tanstack-ai-drizzle-schema --out src/db +``` + +Point drizzle-kit at the emitted file, generate, and migrate: + +```ts ignore +import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + dialect: 'sqlite', + schema: ['./src/db/schema.ts', './src/db/tanstack-ai-schema.ts'], + out: './drizzle', +}) +``` + +```bash +pnpm exec drizzle-kit generate +pnpm exec drizzle-kit migrate +``` + +Pass the schema into the runtime: + +```ts +import { drizzlePersistence } from '@tanstack/ai-persistence-drizzle' +import { schema } from './db/tanstack-ai-schema' +import { db } from './db' + +export const persistence = drizzlePersistence(db, { + provider: 'sqlite', + schema, +}) +``` + +For local Node development without a kit journal yet, the `/sqlite` factory can +bootstrap stock tables at runtime (`ensureTables`, default `true`). That is not +a migration system — see [Drizzle](./drizzle). + +## Cloudflare D1 + +D1 is SQLite. Use the same **schema-first Drizzle** workflow as above — this +package does **not** ship D1 SQL. + +1. Re-export `@tanstack/ai-persistence-drizzle/sqlite-schema` or emit a starter + with `tanstack-ai-drizzle-schema`. +2. Generate migrations with drizzle-kit (`dialect: 'sqlite'`). +3. Point Wrangler `d1_databases[].migrations_dir` at that journal and apply: + +```bash +wrangler d1 migrations apply tanstack-ai-state --local +wrangler d1 migrations apply tanstack-ai-state --remote +``` + +Runtime: `cloudflarePersistence({ d1 })` (stock schema) or +`drizzlePersistence(drizzle(d1, { schema }), { provider: 'sqlite', schema })` +for a project-owned schema. See [Cloudflare](./cloudflare). + +Durable Object **locks** do not use the D1 table journal — configure their +bindings and Durable Object migration tags in Wrangler separately. + +## Prisma + +Prisma ships a provider-neutral models fragment, then delegates SQL generation +to your application: + +```bash +pnpm exec tanstack-ai-prisma-models --out prisma/schema +pnpm prisma migrate dev --name add-tanstack-ai-persistence +pnpm prisma generate +pnpm prisma migrate deploy +``` + +The copied fragment contains no datasource or generator. Keep those in your +application schema and commit the native migration Prisma creates for your +provider. + +## Custom stores + +Custom `AIPersistence` adapters own their schema and migrations entirely. +Maintain compatibility with the public store records and method semantics; +TanStack AI does not inspect your table layout. + +## Upgrade discipline + +1. Read the package release notes for schema contract changes. +2. Refresh emitted schema / models fragments in a reviewable branch. +3. Inspect the drizzle-kit or Prisma diff rather than forcing overwrites. +4. Back up production state where required. +5. Apply migrations before deploying code that depends on them. +6. Keep rollback and partial-deployment behavior explicit. diff --git a/docs/persistence/overview.md b/docs/persistence/overview.md new file mode 100644 index 000000000..8743f59e6 --- /dev/null +++ b/docs/persistence/overview.md @@ -0,0 +1,284 @@ +--- +title: Persistence Overview +id: overview +description: "How durability and persistence fit together in TanStack AI: keep a stream alive through a dropped connection, restore a conversation after a reload, and keep an authoritative server record. Learn the two layers and when to pick each." +keywords: + - persistence + - durability + - resumable streams + - rehydrate conversation + - page reload + - server authoritative + - client authoritative +--- + +# Durability and Persistence + +Three things can go wrong with an AI chat, and they have different fixes: + +1. The connection drops mid-answer. The user watched half a reply appear, then the socket died. You don't want to re-run the model and pay for it twice. +2. The user reloads the page. The whole conversation is gone, because it only lived in memory. +3. The user opens the app on another device, or your server restarts. There is no record of the conversation anywhere durable. + +TanStack AI solves these with two independent layers. You can use either alone or both together. + +## The two layers + +| Layer | Answers | Lives | Docs | +| --- | --- | --- | --- | +| **Delivery durability** | "how do I reconnect to a stream that's still running?" | a per-run log, keyed by `runId` | [Resumable Streams](../resumable-streams/overview) | +| **State persistence** | "what is the conversation, and is it still there later?" | a durable store (client and/or server) | this section | + +They share no code and solve different problems. Delivery durability replays a live byte stream so a dropped connection resumes exactly where it stopped. State persistence stores the conversation itself, so it survives a reload or exists on another device. A replayable stream is not a saved conversation, and a saved conversation is not a live stream. Real apps usually want both. + +## State persistence has two halves + +Persistence runs on the client, the server, or both. They are independent, and they answer different questions. + +| Half | Stores | Survives | Use it for | +| --- | --- | --- | --- | +| **Client** ([Client persistence](./client-persistence)) | the transcript + a resume pointer, in `localStorage` / `sessionStorage` / `IndexedDB` | a page reload in that browser | instant restore on reload, SPA / offline apps | +| **Server** ([Chat persistence](./chat-persistence)) | messages, run status, interrupts, in SQL / D1 / your store | a server restart, and reaches every device | multi-device, audit, durable approvals | + +### 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()`: + +```ts +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { withPersistence } from '@tanstack/ai-persistence' +import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite' + +const persistence = sqlitePersistence({ + url: 'file:.tanstack-ai/state.sqlite', +}) + +export async function POST(request: Request) { + const params = await chatParamsFromRequest(request) + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages: params.messages, + threadId: params.threadId, + runId: params.runId, + ...(params.resume ? { resume: params.resume } : {}), + middleware: [withPersistence(persistence)], + }) + return toServerSentEventsResponse(stream) +} +``` + +The minimal client setup adds one option to `useChat`: + +```tsx +import { + fetchServerSentEvents, + localStoragePersistence, + useChat, +} from '@tanstack/ai-react' + +function Chat() { + const { messages, sendMessage } = useChat({ + threadId: 'support-chat', + connection: fetchServerSentEvents('/api/chat'), + persistence: localStoragePersistence(), + }) + // ... +} +``` + +## Who owns the history: client or server + +When both halves are on, one rule decides which copy is authoritative, and you pick it per turn by what the client sends as `messages`: + +- **Non-empty `messages`** means "this is the full history." On finish the server overwrites its stored thread with it. The client stays authoritative; the server mirrors. +- **Empty `messages`** means "continue from your own copy." The server loads its stored transcript and runs from there. The server is authoritative; the client is a cache. + +That single rule lets the two copies coexist without a merge conflict. Two postures come out of it: + +- **Client-authoritative**: keep sending the full transcript. `localStorage` is the truth, the server store is a durable backup. Closest to a pure SPA. +- **Server-authoritative**: send empty `messages` and let the server own history. The same thread then opens identically on another device, or after the browser cache is cleared. + +## What happens on a page reload + +On load, `useChat` reads the client record and acts on what it finds: + +1. **The run had finished.** The record has the transcript, no resume pointer. The conversation paints instantly from storage. No network. (client persistence alone) +2. **The run was paused on an interrupt.** The resume pointer carries the pending interrupts. The transcript paints and the approval UI comes back exactly as it was. (client persistence alone) +3. **The run was still streaming.** The transcript paints from storage, then the client rejoins the live run through the durability log and the reply finishes in place. This is the one case that needs **both** layers: persistence supplies the transcript and the `runId`, durability replays the rest. (client persistence + delivery durability) + +A dropped connection while the page is still open is simpler: delivery durability reconnects on its own, no persistence needed. Persistence matters once the page itself is gone. + +If you run server-authoritative with the transcript kept off the client (see [Client persistence](./client-persistence)), the reload paints from a server read instead of `localStorage`. The delivery log cannot supply that history: it holds one run, not the whole thread. + +## When to pick each + +| You want | Turn on | +| --- | --- | +| A dropped connection to resume the same answer | Delivery durability ([Resumable Streams](../resumable-streams/overview)) | +| The conversation to still be there after a reload | [Client persistence](./client-persistence) | +| Reload durability without caching big histories client-side | Client persistence with `{ store, messages: false }` | +| The same conversation on another device, or after a server restart | Server persistence ([Chat persistence](./chat-persistence)) | +| Pause for a human approval and resume it later, durably | Server persistence with an `interrupts` store | +| A mid-stream reload to pick up the live answer | Client persistence + delivery durability together | + +Most production chat apps end up with all three: delivery durability on the route, client persistence for instant reload, and server persistence as the record of record. + +## What we recommend + +For a real multi-user app, one combination beats the rest: + +1. **Client: cache only the resume pointer** with `persistence: { store, messages: false }`. The browser holds a few bytes (which run to rejoin, which interrupts are pending), never the transcript. +2. **Server: `withPersistence`** owns the authoritative history, run status, and durable interrupts. +3. **One `GET` endpoint that does two jobs**: rehydrate the conversation from the store, and resume an in-flight durable stream. + +The server route: + +```ts +import { + chat, + chatParamsFromRequest, + memoryStream, + resumeServerSentEventsResponse, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { + reconstructChat, + withPersistence, +} from '@tanstack/ai-persistence' +import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite' + +const persistence = sqlitePersistence({ + url: 'file:.tanstack-ai/state.sqlite', +}) + +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 only the pointer. `useChat` calls that `GET` for you on mount: + +```tsx +import { + fetchServerSentEvents, + localStoragePersistence, + useChat, +} from '@tanstack/ai-react' + +const store = localStoragePersistence() + +function Chat() { + const { messages, sendMessage } = useChat({ + threadId: 'support-chat', + connection: fetchServerSentEvents('/api/chat'), + persistence: { store, messages: false }, + }) + // 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 tiny resume pointer is enough to rejoin a mid-stream run and restore pending interrupts instantly, so dropping the transcript costs nothing on reload. +- **No wasted work.** The `GET` reuses the same route as the durable-stream resume, and `loadThread` returns ready-made messages instead of replaying a stream to reconstruct them. + +Client-only persistence can't do multi-device and bloats storage. Caching everything client-side duplicates the source of truth. Server-only without the resume pointer can't rejoin a live run after a reload without a round-trip. This combination avoids all three. + +## The store contract + +Server **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` +(full packaged backend), `ChatWithInterruptsStores`. See [Controls](./controls). + +**Locks** (cross-worker coordination) are a separate concern: use `withLocks` +and a `LockStore` implementation, not a fifth state store. + +## Where to go next + +- [Chat persistence](./chat-persistence): the server middleware, the authoritative-history contract, and durable interrupts. +- [Client persistence](./client-persistence): client reload restore, the `messages` lever, storage backends, and mid-stream rejoin. +- [Controls](./controls): compose backends per store and choose which stores to run. +- Backends: [Drizzle](./drizzle), [Prisma](./prisma), [Cloudflare](./cloudflare), or your own [Custom stores](./custom-stores). +- [Resumable streams](../resumable-streams/overview): the delivery-durability layer in full. +- [Internals](./internals): the middleware lifecycle and composition mechanics behind every backend. diff --git a/docs/persistence/prisma.md b/docs/persistence/prisma.md new file mode 100644 index 000000000..4a5c3203d --- /dev/null +++ b/docs/persistence/prisma.md @@ -0,0 +1,170 @@ +--- +title: Persistence with Prisma +id: prisma +--- + +# Persistence with Prisma + +`@tanstack/ai-persistence-prisma` accepts your generated and migrated +`PrismaClient`. The package provides a provider-neutral models fragment, not a +datasource, generator, connection URL, or prebuilt SQL migration. + +> **Runnable example:** `examples/persistent-chat-prisma` in this repo is this +> guide end to end on **Prisma 7** — the persistent-chat demo (streaming, tool +> calls, durable approval interrupts) over SQLite with the copied models +> fragment, `prisma.config.ts`, native migrations, and the better-sqlite3 driver +> adapter. + +## Prisma 6 and 7 + +Both major versions are supported. `prismaPersistence` types its argument +structurally, so it does not matter where your client comes from — it only +reads the model delegates off it at runtime, and the delegate query API is the +same across versions. + +- **Prisma 6** (`prisma-client-js` generator): the client is generated into + `node_modules`, so you `import { PrismaClient } from '@prisma/client'`. +- **Prisma 7** (`prisma-client` generator): the client is generated to the + `output` path you configure and is no longer exported from `@prisma/client`, + so you import it from that path, e.g. + `import { PrismaClient } from './generated/prisma/client'`. + +The samples below use the Prisma 6 import; on Prisma 7 swap it for your +generated output path. Everything else is identical. + +## Copy the models fragment + +```bash +pnpm exec tanstack-ai-prisma-models --out prisma/schema +``` + +This writes `tanstack-ai.prisma`, containing only the TanStack AI models. Point +Prisma at that multi-file schema directory alongside your application's +datasource, generator, and models. + +Use `--stdout` to inspect the fragment. The CLI refuses to overwrite a +divergent file unless `--force` is passed. + +The same asset is available programmatically: + +```ts +import { + prismaModels, + prismaModelsFilename, +} from '@tanstack/ai-persistence-prisma' + +console.log(prismaModelsFilename) +console.log(prismaModels) +``` + +## Generate a native migration + +Use Prisma's normal workflow for your selected provider: + +```bash +pnpm prisma migrate dev --name add-tanstack-ai-persistence +pnpm prisma generate +``` + +Deploy the resulting application-owned migrations normally: + +```bash +pnpm prisma migrate deploy +``` + +This lets Prisma generate provider-specific SQL and integrate the models with +the rest of your schema history. + +## Create persistence + +```ts ignore +import { PrismaClient } from '@prisma/client' +import { prismaPersistence } from '@tanstack/ai-persistence-prisma' + +const prisma = new PrismaClient() +export const persistence = prismaPersistence(prisma) +``` + +The adapter provides messages, runs, interrupts, and metadata. The application +owns client connection and shutdown lifecycle. + +## Use it with chat + +```ts +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { withPersistence } from '@tanstack/ai-persistence' +import { persistence } from './persistence' + +export async function POST(request: Request) { + const params = await chatParamsFromRequest(request) + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages: params.messages, + threadId: params.threadId, + runId: params.runId, + ...(params.resume ? { resume: params.resume } : {}), + middleware: [withPersistence(persistence)], + }) + + return toServerSentEventsResponse(stream) +} +``` + +`prismaPersistence` provides state stores only (messages, runs, interrupts, +metadata). Locks are separate: use `withLocks` with a distributed `LockStore` +(for example `createDurableObjectLockStore` from +`@tanstack/ai-persistence-cloudflare`) when multi-instance coordination is +required. + +## Model layout + +The fragment maps four persisted store contracts to `Message`, `Run`, +`Interrupt`, and `Metadata` models. IDs and timestamps use portable Prisma +types so the application can generate migrations for its chosen provider. + +## Rename the models + +The fragment is yours once copied, and its model names are generic — an +application often already has a `Message` or `Run` model. Rename the TanStack +AI models freely and map each store to the renamed client delegate: + +```prisma +/// Renamed from `Message` to avoid a collision. +model ChatMessage { + threadId String @id @map("thread_id") + messagesJson String @map("messages_json") + + @@map("messages") +} +``` + +```ts ignore +import { PrismaClient } from '@prisma/client' +import { prismaPersistence } from '@tanstack/ai-persistence-prisma' + +const prisma = new PrismaClient() + +export const persistence = prismaPersistence(prisma, { + models: { messages: 'chatMessage' }, +}) +``` + +Map values are the camelCase client accessor names (`prisma.chatMessage`), not +the PascalCase model names. Unmapped stores keep their default names +(`message`, `run`, `interrupt`, `metadata`), and `prismaPersistence` throws a +`PrismaModelError` naming every store whose delegate cannot be found. + +What stays fixed is the client-level field surface: keep the fragment's field +names and types, and the default composite unique alias `scope_key` on the +metadata model. Everything else is yours: + +- **Database names** — table and column names are already governed by + `@@map` / `@map` in your copy; change them without touching the runtime. +- **Extra app-owned fields** — for example a `userId` on the messages model to + scope threads to users. Keep added fields optional or defaulted so the + store creates succeed; the TanStack AI stores never read or write them. diff --git a/docs/persistence/sql-backends.md b/docs/persistence/sql-backends.md new file mode 100644 index 000000000..9b2195163 --- /dev/null +++ b/docs/persistence/sql-backends.md @@ -0,0 +1,88 @@ +--- +title: SQL Backends +id: sql-backends +--- + +# SQL Backends + +TanStack AI ships two SQL-oriented state adapters with different ownership +models. + +| Adapter | Database support | Connection ownership | Schema workflow | +| --- | --- | --- | --- | +| `@tanstack/ai-persistence-drizzle` | SQLite-family | Bring a migrated Drizzle DB, or use Node `/sqlite` | Emit schema via CLI; your drizzle-kit owns migrations | +| `@tanstack/ai-persistence-prisma` | Providers supported by your Prisma schema | Bring your generated `PrismaClient` | Copy models fragment, then use Prisma migrate | + +The Drizzle adapter does not ship SQL migrations. Pass a required `schema` to +`drizzlePersistence`. For a non-SQLite Drizzle database, implement the public +`AIPersistence` store interfaces for that dialect (or use Prisma). + +## Local SQLite + +```ts +import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite' + +export const persistence = sqlitePersistence({ + url: 'file:.tanstack-ai/state.sqlite', +}) +``` + +Uses the default schema and runtime table bootstrap. For production, emit the +schema, migrate with drizzle-kit, and pass `{ schema, ensureTables: false }`. + +## Existing SQLite or D1 Drizzle database + +```ts ignore +import { drizzle } from 'drizzle-orm/d1' +import { + createDefaultSqliteSchema, + drizzlePersistence, +} from '@tanstack/ai-persistence-drizzle' + +export function createPersistence(state: D1Database) { + const schema = createDefaultSqliteSchema() + return drizzlePersistence(drizzle(state, { schema }), { + provider: 'sqlite', + schema, + }) +} +``` + +The package root is edge-safe; `/sqlite` is Node-only. Prefer a project-owned +schema from `tanstack-ai-drizzle-schema` over the default factory when you run +drizzle-kit. + +## Prisma + +```ts ignore +import { PrismaClient } from '@prisma/client' +import { prismaPersistence } from '@tanstack/ai-persistence-prisma' + +const prisma = new PrismaClient() +export const persistence = prismaPersistence(prisma) +``` + +Copy the package's models fragment and create provider-native migrations before +constructing the adapter. See [Prisma](./prisma). + +## Store coverage + +Both adapters provide **state** stores: messages, runs, interrupts, and +metadata. Locks are a separate concern — when multiple processes can mutate the +same critical section, add `withLocks` with a distributed `LockStore`: + +```ts +import { withPersistence, withLocks } from '@tanstack/ai-persistence' +import { persistence } from './persistence' +import { distributedLocks } from './locks' + +middleware: [ + withPersistence(persistence), + withLocks(distributedLocks), +] +``` + +For Cloudflare D1, use the Drizzle SQLite path above (or the thin +`cloudflarePersistence({ d1 })` wrapper). Durable Object locks live in +[Cloudflare Persistence](./cloudflare). For another SQL library, start with +[Custom Stores](./custom-stores). diff --git a/docs/resumable-streams/advanced.md b/docs/resumable-streams/advanced.md index 640e172b4..30fb1e965 100644 --- a/docs/resumable-streams/advanced.md +++ b/docs/resumable-streams/advanced.md @@ -247,4 +247,4 @@ response helper. The durability log replays chunks. It is not a queryable source of truth for thread messages or conversation history. It answers "what did this run stream?", not "what has this user said?". Keep authoritative state in your own storage. -See [Persistence](../chat/persistence) for the client-side options. +See [Client persistence](../persistence/client-persistence) for the client-side options. diff --git a/docs/resumable-streams/overview.md b/docs/resumable-streams/overview.md index 01f2b856f..3fe9067c4 100644 --- a/docs/resumable-streams/overview.md +++ b/docs/resumable-streams/overview.md @@ -23,6 +23,11 @@ response. The adapter records every chunk to an ordered log before delivery and tags each event with an opaque offset. On reconnect the client resends the last offset and the server replays from the log instead of re-running the model. +This is the delivery layer: it resumes a live stream. Saving the conversation so +it survives a reload or reaches another device is a separate layer. For how the +two fit together and when to pick each, see +[Durability and Persistence](../persistence/overview). + Three steps: pick an adapter, wrap your response with it, add a `GET` handler. ## 1. Pick an adapter diff --git a/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/persistent-chat-drizzle/.gitignore b/examples/persistent-chat-drizzle/.gitignore new file mode 100644 index 000000000..6a67c7c24 --- /dev/null +++ b/examples/persistent-chat-drizzle/.gitignore @@ -0,0 +1,8 @@ +node_modules +.env +.data +.output +.nitro +.vite +dist +build diff --git a/examples/persistent-chat-drizzle/README.md b/examples/persistent-chat-drizzle/README.md new file mode 100644 index 000000000..fa107cf79 --- /dev/null +++ b/examples/persistent-chat-drizzle/README.md @@ -0,0 +1,37 @@ +# Persistent Chat — Drizzle (SQLite) + +The [`ts-react-chat` persistent-chat demo](../ts-react-chat/src/routes/persistent-chat.tsx), +backed by `@tanstack/ai-persistence-drizzle` over SQLite with **real migrations** +instead of the runtime table bootstrap. + +Server-authoritative persistence: the client caches only a resume pointer +(`messages: false`); on mount `useChat` hydrates the thread from the server by +its id. Start a long reply, roll dice, or send an email (which pauses for +approval), then reload or open the URL elsewhere — it resumes from SQLite. + +## How the schema and migrations work + +- `src/db/tanstack-ai-schema.ts` re-exports the package's stock SQLite tables so + drizzle-kit owns the DDL in this repo. +- `drizzle.config.ts` points drizzle-kit at that module. +- `pnpm db:generate` emits versioned SQL into `./drizzle` (committed). +- `scripts/migrate.mjs` applies those SQL files directly with Node's built-in + `node:sqlite` driver, tracking applied files in a `__migrations` table — no + drizzle-kit runtime driver required. `predev` runs it automatically. +- The runtime uses `sqlitePersistence({ schema, ensureTables: false })`, so the + migrations (not a runtime bootstrap) create the tables. + +## Run it + +```bash +# From the repo root, once: build the workspace packages this app imports. +pnpm build + +cd examples/persistent-chat-drizzle +echo "OPENAI_API_KEY=sk-..." > .env # any OpenAI key; the demo uses gpt-5.5 + +pnpm db:generate # emit ./drizzle SQL (already committed; safe to re-run) +pnpm dev # predev applies migrations, then serves on :3020 +``` + +Open http://localhost:3020. diff --git a/examples/persistent-chat-drizzle/drizzle.config.ts b/examples/persistent-chat-drizzle/drizzle.config.ts new file mode 100644 index 000000000..ea406f3bd --- /dev/null +++ b/examples/persistent-chat-drizzle/drizzle.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'drizzle-kit' + +// `src/db/tanstack-ai-schema.ts` is the app-owned schema starter emitted from +// `@tanstack/ai-persistence-drizzle` (self-contained drizzle-orm tables), so +// `drizzle-kit generate` emits DDL for the TanStack AI tables into ./drizzle. +// Those SQL files are committed and applied by scripts/migrate.mjs (no +// drizzle-kit runtime driver). +export default defineConfig({ + dialect: 'sqlite', + schema: './src/db/tanstack-ai-schema.ts', + out: './drizzle', +}) diff --git a/examples/persistent-chat-drizzle/drizzle/0000_loving_snowbird.sql b/examples/persistent-chat-drizzle/drizzle/0000_loving_snowbird.sql new file mode 100644 index 000000000..a37beda6e --- /dev/null +++ b/examples/persistent-chat-drizzle/drizzle/0000_loving_snowbird.sql @@ -0,0 +1,34 @@ +CREATE TABLE `interrupts` ( + `interrupt_id` text PRIMARY KEY NOT NULL, + `run_id` text NOT NULL, + `thread_id` text NOT NULL, + `status` text NOT NULL, + `requested_at` integer NOT NULL, + `resolved_at` integer, + `payload_json` text NOT NULL, + `response_json` text +); +--> statement-breakpoint +CREATE INDEX `interrupts_thread_id_idx` ON `interrupts` (`thread_id`);--> statement-breakpoint +CREATE INDEX `interrupts_run_id_idx` ON `interrupts` (`run_id`);--> statement-breakpoint +CREATE TABLE `messages` ( + `thread_id` text PRIMARY KEY NOT NULL, + `messages_json` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `metadata` ( + `scope` text NOT NULL, + `key` text NOT NULL, + `value_json` text NOT NULL, + PRIMARY KEY(`scope`, `key`) +); +--> statement-breakpoint +CREATE TABLE `runs` ( + `run_id` text PRIMARY KEY NOT NULL, + `thread_id` text NOT NULL, + `status` text NOT NULL, + `started_at` integer NOT NULL, + `finished_at` integer, + `error` text, + `usage_json` text +); diff --git a/examples/persistent-chat-drizzle/drizzle/meta/0000_snapshot.json b/examples/persistent-chat-drizzle/drizzle/meta/0000_snapshot.json new file mode 100644 index 000000000..765980581 --- /dev/null +++ b/examples/persistent-chat-drizzle/drizzle/meta/0000_snapshot.json @@ -0,0 +1,214 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "e83046d0-8cfc-4402-9d10-875dec7802b9", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "interrupts": { + "name": "interrupts", + "columns": { + "interrupt_id": { + "name": "interrupt_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_at": { + "name": "requested_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response_json": { + "name": "response_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "interrupts_thread_id_idx": { + "name": "interrupts_thread_id_idx", + "columns": ["thread_id"], + "isUnique": false + }, + "interrupts_run_id_idx": { + "name": "interrupts_run_id_idx", + "columns": ["run_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "messages_json": { + "name": "messages_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "metadata": { + "name": "metadata", + "columns": { + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value_json": { + "name": "value_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "metadata_scope_key_pk": { + "columns": ["scope", "key"], + "name": "metadata_scope_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "runs": { + "name": "runs", + "columns": { + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage_json": { + "name": "usage_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/examples/persistent-chat-drizzle/drizzle/meta/_journal.json b/examples/persistent-chat-drizzle/drizzle/meta/_journal.json new file mode 100644 index 000000000..c2309fe2b --- /dev/null +++ b/examples/persistent-chat-drizzle/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1784911459678, + "tag": "0000_loving_snowbird", + "breakpoints": true + } + ] +} diff --git a/examples/persistent-chat-drizzle/package.json b/examples/persistent-chat-drizzle/package.json new file mode 100644 index 000000000..625f69720 --- /dev/null +++ b/examples/persistent-chat-drizzle/package.json @@ -0,0 +1,40 @@ +{ + "name": "persistent-chat-drizzle", + "private": true, + "type": "module", + "scripts": { + "db:generate": "drizzle-kit generate", + "db:migrate": "node scripts/migrate.mjs", + "predev": "node scripts/migrate.mjs", + "dev": "vite dev --port 3020", + "build": "vite build", + "serve": "vite preview", + "test": "exit 0", + "test:types": "tsc --noEmit" + }, + "dependencies": { + "@tanstack/ai": "workspace:*", + "@tanstack/ai-client": "workspace:*", + "@tanstack/ai-openai": "workspace:*", + "@tanstack/ai-persistence": "workspace:*", + "@tanstack/ai-persistence-drizzle": "workspace:*", + "@tanstack/ai-react": "workspace:*", + "@tanstack/react-router": "^1.158.4", + "@tanstack/react-start": "^1.159.0", + "@tanstack/router-plugin": "^1.158.4", + "drizzle-orm": "^0.45.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "zod": "^4.2.0" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.2", + "drizzle-kit": "^0.31.0", + "nitro": "3.0.260610-beta", + "typescript": "5.9.3", + "vite": "^8.1.4" + } +} diff --git a/examples/persistent-chat-drizzle/scripts/migrate.mjs b/examples/persistent-chat-drizzle/scripts/migrate.mjs new file mode 100644 index 000000000..41dac5347 --- /dev/null +++ b/examples/persistent-chat-drizzle/scripts/migrate.mjs @@ -0,0 +1,54 @@ +// Apply the committed drizzle-kit SQL migrations directly with Node's built-in +// SQLite driver — no drizzle-kit runtime driver (better-sqlite3/libsql) needed. +// Tracks applied files in `__migrations` so re-runs are a no-op, and splits each +// file on drizzle's `--> statement-breakpoint` markers. +import { mkdirSync, readFileSync, readdirSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' + +const DB_PATH = './.data/persistent-chat.db' +const MIGRATIONS_DIR = './drizzle' + +mkdirSync(dirname(DB_PATH), { recursive: true }) +const db = new DatabaseSync(DB_PATH) +db.exec( + `CREATE TABLE IF NOT EXISTS __migrations (name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)`, +) + +const applied = new Set( + db + .prepare('SELECT name FROM __migrations') + .all() + .map((row) => row.name), +) + +let files = [] +try { + files = readdirSync(MIGRATIONS_DIR) + .filter((f) => f.endsWith('.sql')) + .sort() +} catch { + console.error( + `No migrations found in ${MIGRATIONS_DIR}. Run \`pnpm db:generate\` first.`, + ) + process.exit(1) +} + +let ran = 0 +for (const file of files) { + if (applied.has(file)) continue + const sql = readFileSync(join(MIGRATIONS_DIR, file), 'utf8') + for (const statement of sql.split('--> statement-breakpoint')) { + const trimmed = statement.trim() + if (trimmed) db.exec(trimmed) + } + db.prepare('INSERT INTO __migrations (name, applied_at) VALUES (?, ?)').run( + file, + Date.now(), + ) + ran++ + console.log(`applied ${file}`) +} + +console.log(ran === 0 ? 'migrations up to date' : `applied ${ran} migration(s)`) +db.close() diff --git a/examples/persistent-chat-drizzle/src/db/tanstack-ai-schema.ts b/examples/persistent-chat-drizzle/src/db/tanstack-ai-schema.ts new file mode 100644 index 000000000..15a3af11c --- /dev/null +++ b/examples/persistent-chat-drizzle/src/db/tanstack-ai-schema.ts @@ -0,0 +1,91 @@ +/** + * TanStack AI persistence schema — emitted by `tanstack-ai-drizzle-schema`. + * + * This file is yours. Add it to your drizzle-kit `schema` paths so your own + * migration journal owns the DDL, then pass it back to the runtime: + * + * ```ts + * import { drizzlePersistence } from '@tanstack/ai-persistence-drizzle' + * import { schema } from './tanstack-ai-schema' + * + * const persistence = drizzlePersistence(db, { provider: 'sqlite', schema }) + * ``` + * + * You may rename tables and columns (or drop the explicit column names below + * and rely on your drizzle `casing` configuration) and add extra app-owned + * columns — keep added columns nullable or defaulted so the runtime's inserts + * succeed, and keep the columns below with these data shapes. + * + * This package does not ship SQL migrations. Generate and apply them with + * drizzle-kit in this project. + */ +import { + index, + integer, + primaryKey, + sqliteTable, + text, +} from 'drizzle-orm/sqlite-core' +import type { InterruptRecord, RunStatus } from '@tanstack/ai-persistence' +import type { ModelMessage, TokenUsage } from '@tanstack/ai' + +/** Thread message history (`MessageStore`). */ +export const messages = sqliteTable('messages', { + threadId: text('thread_id').primaryKey(), + messagesJson: text('messages_json', { mode: 'json' }) + .$type>() + .notNull(), +}) + +/** Run lifecycle records (`RunStore`). */ +export const runs = sqliteTable('runs', { + runId: text('run_id').primaryKey(), + threadId: text('thread_id').notNull(), + status: text('status').$type().notNull(), + startedAt: integer('started_at').notNull(), + finishedAt: integer('finished_at'), + error: text('error'), + usageJson: text('usage_json', { mode: 'json' }).$type(), +}) + +/** Interrupt / approval records (`InterruptStore`). */ +export const interrupts = sqliteTable( + 'interrupts', + { + interruptId: text('interrupt_id').primaryKey(), + runId: text('run_id').notNull(), + threadId: text('thread_id').notNull(), + status: text('status').$type().notNull(), + requestedAt: integer('requested_at').notNull(), + resolvedAt: integer('resolved_at'), + payloadJson: text('payload_json', { mode: 'json' }) + .$type>() + .notNull(), + responseJson: text('response_json', { mode: 'json' }).$type(), + }, + // The runtime lists interrupts by thread and by run; keep (or extend) these + // lookup indexes to taste — this file is yours. + (table) => [ + index('interrupts_thread_id_idx').on(table.threadId), + index('interrupts_run_id_idx').on(table.runId), + ], +) + +/** Scoped key/value metadata (`MetadataStore`). */ +export const metadata = sqliteTable( + 'metadata', + { + scope: text('scope').notNull(), + key: text('key').notNull(), + valueJson: text('value_json', { mode: 'json' }).$type().notNull(), + }, + (table) => [primaryKey({ columns: [table.scope, table.key] })], +) + +/** The full state schema, for `drizzlePersistence(db, { schema })` and drizzle-kit. */ +export const schema = { + messages, + runs, + interrupts, + metadata, +} diff --git a/examples/persistent-chat-drizzle/src/lib/persistent-chat-store.ts b/examples/persistent-chat-drizzle/src/lib/persistent-chat-store.ts new file mode 100644 index 000000000..d9d7e7571 --- /dev/null +++ b/examples/persistent-chat-drizzle/src/lib/persistent-chat-store.ts @@ -0,0 +1,24 @@ +import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite' + +let instance: ReturnType | undefined + +/** + * One SQLite-backed persistence store, shared by the POST handler (writes the + * transcript, run records, and interrupts) and the GET handler (durability + * replay / `reconstructChat`). + * + * Tables are created by the committed drizzle-kit migrations in `./drizzle`, + * generated from the app-owned `src/db/tanstack-ai-schema.ts` and applied by + * `scripts/migrate.mjs` (which `predev` runs) — so `ensureTables: false`, no + * runtime `CREATE TABLE IF NOT EXISTS` bootstrap. The runtime operates on the + * package's stock default schema, which the emitted starter is structurally + * identical to, so the migrated tables match. (Pass your own `schema` here only + * if you customize the tables and keep a single `drizzle-orm` instance.) + * `.data/` is gitignored. + */ +export function persistentChatPersistence() { + return (instance ??= sqlitePersistence({ + url: './.data/persistent-chat.db', + ensureTables: false, + })) +} diff --git a/examples/persistent-chat-drizzle/src/lib/persistent-chat-tools.ts b/examples/persistent-chat-drizzle/src/lib/persistent-chat-tools.ts new file mode 100644 index 000000000..184d71c7d --- /dev/null +++ b/examples/persistent-chat-drizzle/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/persistent-chat-drizzle/src/routeTree.gen.ts b/examples/persistent-chat-drizzle/src/routeTree.gen.ts new file mode 100644 index 000000000..54ba1c203 --- /dev/null +++ b/examples/persistent-chat-drizzle/src/routeTree.gen.ts @@ -0,0 +1,104 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as PersistentChatRouteImport } from './routes/persistent-chat' +import { Route as IndexRouteImport } from './routes/index' +import { Route as ApiPersistentChatRouteImport } from './routes/api.persistent-chat' + +const PersistentChatRoute = PersistentChatRouteImport.update({ + id: '/persistent-chat', + path: '/persistent-chat', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const ApiPersistentChatRoute = ApiPersistentChatRouteImport.update({ + id: '/api/persistent-chat', + path: '/api/persistent-chat', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/persistent-chat': typeof PersistentChatRoute + '/api/persistent-chat': typeof ApiPersistentChatRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/persistent-chat': typeof PersistentChatRoute + '/api/persistent-chat': typeof ApiPersistentChatRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/persistent-chat': typeof PersistentChatRoute + '/api/persistent-chat': typeof ApiPersistentChatRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/persistent-chat' | '/api/persistent-chat' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/persistent-chat' | '/api/persistent-chat' + id: '__root__' | '/' | '/persistent-chat' | '/api/persistent-chat' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + PersistentChatRoute: typeof PersistentChatRoute + ApiPersistentChatRoute: typeof ApiPersistentChatRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/persistent-chat': { + id: '/persistent-chat' + path: '/persistent-chat' + fullPath: '/persistent-chat' + preLoaderRoute: typeof PersistentChatRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/api/persistent-chat': { + id: '/api/persistent-chat' + path: '/api/persistent-chat' + fullPath: '/api/persistent-chat' + preLoaderRoute: typeof ApiPersistentChatRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + PersistentChatRoute: PersistentChatRoute, + ApiPersistentChatRoute: ApiPersistentChatRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/examples/persistent-chat-drizzle/src/router.tsx b/examples/persistent-chat-drizzle/src/router.tsx new file mode 100644 index 000000000..ee1edab88 --- /dev/null +++ b/examples/persistent-chat-drizzle/src/router.tsx @@ -0,0 +1,13 @@ +import { createRouter } from '@tanstack/react-router' + +// Import the generated route tree +import { routeTree } from './routeTree.gen' + +// Create a new router instance +export const getRouter = () => { + return createRouter({ + routeTree, + scrollRestoration: true, + defaultPreloadStaleTime: 0, + }) +} diff --git a/examples/persistent-chat-drizzle/src/routes/__root.tsx b/examples/persistent-chat-drizzle/src/routes/__root.tsx new file mode 100644 index 000000000..c40bfc41f --- /dev/null +++ b/examples/persistent-chat-drizzle/src/routes/__root.tsx @@ -0,0 +1,26 @@ +import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + { title: 'Persistent Chat — Drizzle (SQLite)' }, + ], + }), + shellComponent: RootDocument, +}) + +function RootDocument({ children }: { children: React.ReactNode }) { + return ( + + + + + + {children} + + + + ) +} diff --git a/examples/persistent-chat-drizzle/src/routes/api.persistent-chat.ts b/examples/persistent-chat-drizzle/src/routes/api.persistent-chat.ts new file mode 100644 index 000000000..15621d9b7 --- /dev/null +++ b/examples/persistent-chat-drizzle/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: { store, messages: false }`) + * 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 `{ messages: false }`) 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/persistent-chat-drizzle/src/routes/index.tsx b/examples/persistent-chat-drizzle/src/routes/index.tsx new file mode 100644 index 000000000..e7c0e55f5 --- /dev/null +++ b/examples/persistent-chat-drizzle/src/routes/index.tsx @@ -0,0 +1,8 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' + +// The demo lives at /persistent-chat; send the root there. +export const Route = createFileRoute('/')({ + beforeLoad: () => { + throw redirect({ to: '/persistent-chat' }) + }, +}) diff --git a/examples/persistent-chat-drizzle/src/routes/persistent-chat.css b/examples/persistent-chat-drizzle/src/routes/persistent-chat.css new file mode 100644 index 000000000..427df1a7d --- /dev/null +++ b/examples/persistent-chat-drizzle/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/persistent-chat-drizzle/src/routes/persistent-chat.tsx b/examples/persistent-chat-drizzle/src/routes/persistent-chat.tsx new file mode 100644 index 000000000..8a651fa9c --- /dev/null +++ b/examples/persistent-chat-drizzle/src/routes/persistent-chat.tsx @@ -0,0 +1,331 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useEffect, useRef, useState } from 'react' +import { + fetchServerSentEvents, + localStoragePersistence, +} from '@tanstack/ai-client' +import { useChat } from '@tanstack/ai-react' +import { 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 only resume pointers, +// never transcripts. 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 = { store: localStoragePersistence(), messages: false } + +// 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 + only a resume pointer (messages: false); 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/persistent-chat-drizzle/tsconfig.json b/examples/persistent-chat-drizzle/tsconfig.json new file mode 100644 index 000000000..4327646a3 --- /dev/null +++ b/examples/persistent-chat-drizzle/tsconfig.json @@ -0,0 +1,20 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "target": "ES2022", + "jsx": "react-jsx", + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client", "node"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": false, + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + } +} diff --git a/examples/persistent-chat-drizzle/vite.config.ts b/examples/persistent-chat-drizzle/vite.config.ts new file mode 100644 index 000000000..de91346e6 --- /dev/null +++ b/examples/persistent-chat-drizzle/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import viteReact from '@vitejs/plugin-react' +import { nitro } from 'nitro/vite' + +// `@tanstack/ai-persistence-drizzle/sqlite` uses Node's built-in `node:sqlite` +// and is server-only; keep it out of the client optimizer / SSR inline so the +// browser bundle never tries to resolve a Node built-in. +export default defineConfig({ + optimizeDeps: { exclude: ['@tanstack/ai-persistence-drizzle'] }, + ssr: { external: ['@tanstack/ai-persistence-drizzle'] }, + plugins: [nitro(), tanstackStart(), viteReact()], +}) diff --git a/examples/persistent-chat-prisma/.gitignore b/examples/persistent-chat-prisma/.gitignore new file mode 100644 index 000000000..f075e8497 --- /dev/null +++ b/examples/persistent-chat-prisma/.gitignore @@ -0,0 +1,9 @@ +node_modules +.env +.data +.output +.nitro +.vite +dist +build +src/generated diff --git a/examples/persistent-chat-prisma/README.md b/examples/persistent-chat-prisma/README.md new file mode 100644 index 000000000..64d3db578 --- /dev/null +++ b/examples/persistent-chat-prisma/README.md @@ -0,0 +1,41 @@ +# Persistent Chat — Prisma 7 (SQLite) + +The [`ts-react-chat` persistent-chat demo](../ts-react-chat/src/routes/persistent-chat.tsx), +backed by `@tanstack/ai-persistence-prisma` on **Prisma 7** over SQLite with +native Prisma migrations. + +Server-authoritative persistence: the client caches only a resume pointer +(`messages: false`); on mount `useChat` hydrates the thread from the server by +its id. Start a long reply, roll dice, or send an email (which pauses for +approval), then reload or open the URL elsewhere — it resumes from SQLite. + +## How the schema and migrations work + +- `prisma/schema/tanstack-ai.prisma` is the models fragment copied from the + package (`pnpm exec tanstack-ai-prisma-models --out prisma/schema`); it lives + alongside the app's `schema.prisma` (datasource + `prisma-client` generator). +- Prisma 7 config is `prisma.config.ts`. The generator emits an ESM client to + `src/generated/prisma` (gitignored); import it from there, not `@prisma/client`. +- `prisma migrate dev` creates the migrations in `prisma/migrations` (committed); + `prisma migrate deploy` applies them (run by `predev`). +- Prisma 7 uses a driver adapter for SQLite: the runtime wires a + `PrismaBetterSQLite3` adapter into `new PrismaClient({ adapter })`. + +## Run it + +```bash +# From the repo root, once: build the workspace packages this app imports. +pnpm build + +cd examples/persistent-chat-prisma +cat > .env <<'EOF' +OPENAI_API_KEY=sk-... +DATABASE_URL=file:./.data/persistent-chat.db +EOF + +pnpm db:generate # generate the v7 client into src/generated +pnpm exec prisma migrate deploy # apply committed migrations (or `migrate dev` to add new ones) +pnpm dev # serves on :3021 +``` + +Open http://localhost:3021. diff --git a/examples/persistent-chat-prisma/package.json b/examples/persistent-chat-prisma/package.json new file mode 100644 index 000000000..8ce53d9c0 --- /dev/null +++ b/examples/persistent-chat-prisma/package.json @@ -0,0 +1,59 @@ +{ + "name": "persistent-chat-prisma", + "private": true, + "type": "module", + "scripts": { + "db:generate": "prisma generate", + "db:migrate": "prisma migrate deploy", + "predev": "prisma migrate deploy && prisma generate", + "dev": "vite dev --port 3021", + "build": "vite build", + "serve": "vite preview", + "test": "exit 0", + "test:types": "tsc --noEmit" + }, + "dependencies": { + "@prisma/adapter-better-sqlite3": "^7.0.0", + "@prisma/client": "^7.0.0", + "@tanstack/ai": "workspace:*", + "@tanstack/ai-client": "workspace:*", + "@tanstack/ai-openai": "workspace:*", + "@tanstack/ai-persistence": "workspace:*", + "@tanstack/ai-persistence-prisma": "workspace:*", + "@tanstack/ai-react": "workspace:*", + "@tanstack/react-router": "^1.158.4", + "@tanstack/react-start": "^1.159.0", + "@tanstack/router-plugin": "^1.158.4", + "dotenv": "^17.2.3", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "zod": "^4.2.0" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.2", + "nitro": "3.0.260610-beta", + "prisma": "^7.0.0", + "typescript": "5.9.3", + "vite": "^8.1.4" + }, + "nx": { + "targets": { + "db:generate": { + "cache": false + }, + "test:types": { + "dependsOn": [ + "db:generate" + ] + }, + "build": { + "dependsOn": [ + "db:generate" + ] + } + } + } +} diff --git a/examples/persistent-chat-prisma/prisma.config.ts b/examples/persistent-chat-prisma/prisma.config.ts new file mode 100644 index 000000000..ee514f76a --- /dev/null +++ b/examples/persistent-chat-prisma/prisma.config.ts @@ -0,0 +1,22 @@ +import 'dotenv/config' +import { defineConfig } from 'prisma/config' + +// Prisma 7 config. The multi-file schema lives in `prisma/schema` (the app's +// datasource + generator plus the copied TanStack AI models fragment), and +// migrations are written to `prisma/migrations` (committed). +// +// Prisma 7 no longer accepts `url` in the schema datasource — the connection +// URL for Migrate lives here (from `DATABASE_URL` in `.env` when present), +// while the runtime client connects via a driver adapter (see +// `src/lib/persistent-chat-store.ts`). +// +// Fall back to the example's local SQLite path so `prisma generate` works in +// CI / clean checkouts without a committed `.env`. +const databaseUrl = + process.env.DATABASE_URL ?? 'file:./.data/persistent-chat.db' + +export default defineConfig({ + schema: 'prisma/schema', + migrations: { path: 'prisma/migrations' }, + datasource: { url: databaseUrl }, +}) diff --git a/examples/persistent-chat-prisma/prisma/migrations/20260724164954_init/migration.sql b/examples/persistent-chat-prisma/prisma/migrations/20260724164954_init/migration.sql new file mode 100644 index 000000000..4b0f9c916 --- /dev/null +++ b/examples/persistent-chat-prisma/prisma/migrations/20260724164954_init/migration.sql @@ -0,0 +1,37 @@ +-- CreateTable +CREATE TABLE "messages" ( + "thread_id" TEXT NOT NULL PRIMARY KEY, + "messages_json" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "runs" ( + "run_id" TEXT NOT NULL PRIMARY KEY, + "thread_id" TEXT NOT NULL, + "status" TEXT NOT NULL, + "started_at" BIGINT NOT NULL, + "finished_at" BIGINT, + "error" TEXT, + "usage_json" TEXT +); + +-- CreateTable +CREATE TABLE "interrupts" ( + "interrupt_id" TEXT NOT NULL PRIMARY KEY, + "run_id" TEXT NOT NULL, + "thread_id" TEXT NOT NULL, + "status" TEXT NOT NULL, + "requested_at" BIGINT NOT NULL, + "resolved_at" BIGINT, + "payload_json" TEXT NOT NULL, + "response_json" TEXT +); + +-- CreateTable +CREATE TABLE "metadata" ( + "scope" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value_json" TEXT NOT NULL, + + PRIMARY KEY ("scope", "key") +); diff --git a/examples/persistent-chat-prisma/prisma/migrations/migration_lock.toml b/examples/persistent-chat-prisma/prisma/migrations/migration_lock.toml new file mode 100644 index 000000000..2a5a44419 --- /dev/null +++ b/examples/persistent-chat-prisma/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "sqlite" diff --git a/examples/persistent-chat-prisma/prisma/schema/schema.prisma b/examples/persistent-chat-prisma/prisma/schema/schema.prisma new file mode 100644 index 000000000..1568c8d2a --- /dev/null +++ b/examples/persistent-chat-prisma/prisma/schema/schema.prisma @@ -0,0 +1,14 @@ +// App datasource + generator. The TanStack AI persistence models live in the +// sibling `tanstack-ai.prisma` fragment (copied from +// `@tanstack/ai-persistence-prisma`), so this multi-file schema owns them and +// Prisma generates the app's migrations for them. + +generator client { + provider = "prisma-client" + output = "../../src/generated/prisma" + moduleFormat = "esm" +} + +datasource db { + provider = "sqlite" +} diff --git a/examples/persistent-chat-prisma/prisma/schema/tanstack-ai.prisma b/examples/persistent-chat-prisma/prisma/schema/tanstack-ai.prisma new file mode 100644 index 000000000..aa716415b --- /dev/null +++ b/examples/persistent-chat-prisma/prisma/schema/tanstack-ai.prisma @@ -0,0 +1,50 @@ +// Provider-neutral TanStack AI persistence models. +// +// Copy this file into a Prisma multi-file schema directory, then create and +// apply migrations using the application's selected provider and normal Prisma +// workflow. This fragment intentionally contains no datasource or generator. + +/// Thread message history (`MessageStore`). +model Message { + threadId String @id @map("thread_id") + messagesJson String @map("messages_json") + + @@map("messages") +} + +/// Run lifecycle records (`RunStore`). +model Run { + runId String @id @map("run_id") + threadId String @map("thread_id") + status String + startedAt BigInt @map("started_at") + finishedAt BigInt? @map("finished_at") + error String? + usageJson String? @map("usage_json") + + @@map("runs") +} + +/// Interrupt / approval records (`InterruptStore`). +model Interrupt { + interruptId String @id @map("interrupt_id") + runId String @map("run_id") + threadId String @map("thread_id") + status String + requestedAt BigInt @map("requested_at") + resolvedAt BigInt? @map("resolved_at") + payloadJson String @map("payload_json") + responseJson String? @map("response_json") + + @@map("interrupts") +} + +/// Scoped key/value metadata (`MetadataStore`). +model Metadata { + scope String + key String + valueJson String @map("value_json") + + @@id([scope, key]) + @@map("metadata") +} diff --git a/examples/persistent-chat-prisma/src/lib/persistent-chat-store.ts b/examples/persistent-chat-prisma/src/lib/persistent-chat-store.ts new file mode 100644 index 000000000..96804d39f --- /dev/null +++ b/examples/persistent-chat-prisma/src/lib/persistent-chat-store.ts @@ -0,0 +1,26 @@ +import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3' +import { prismaPersistence } from '@tanstack/ai-persistence-prisma' +import { PrismaClient } from '../generated/prisma/client' + +let instance: ReturnType | undefined + +/** + * One Prisma (SQLite) persistence store, shared by the POST handler (writes the + * transcript, run records, and interrupts) and the GET handler (durability + * replay / `reconstructChat`). + * + * Prisma 7 talks to SQLite through the `@prisma/adapter-better-sqlite3` driver + * adapter. The tables come from the committed Prisma migrations in + * `prisma/migrations` (applied by `prisma migrate deploy`, which `predev` runs). + * `prismaPersistence` types its client structurally, so the v7 generated client + * (imported from the generator `output` path, not `@prisma/client`) wires in + * without a type mismatch. `.data/` is gitignored. + */ +export function persistentChatPersistence() { + if (instance) return instance + const adapter = new PrismaBetterSqlite3({ + url: process.env.DATABASE_URL || 'file:./.data/persistent-chat.db', + }) + const prisma = new PrismaClient({ adapter }) + return (instance = prismaPersistence(prisma)) +} diff --git a/examples/persistent-chat-prisma/src/lib/persistent-chat-tools.ts b/examples/persistent-chat-prisma/src/lib/persistent-chat-tools.ts new file mode 100644 index 000000000..184d71c7d --- /dev/null +++ b/examples/persistent-chat-prisma/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/persistent-chat-prisma/src/routeTree.gen.ts b/examples/persistent-chat-prisma/src/routeTree.gen.ts new file mode 100644 index 000000000..54ba1c203 --- /dev/null +++ b/examples/persistent-chat-prisma/src/routeTree.gen.ts @@ -0,0 +1,104 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as PersistentChatRouteImport } from './routes/persistent-chat' +import { Route as IndexRouteImport } from './routes/index' +import { Route as ApiPersistentChatRouteImport } from './routes/api.persistent-chat' + +const PersistentChatRoute = PersistentChatRouteImport.update({ + id: '/persistent-chat', + path: '/persistent-chat', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const ApiPersistentChatRoute = ApiPersistentChatRouteImport.update({ + id: '/api/persistent-chat', + path: '/api/persistent-chat', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/persistent-chat': typeof PersistentChatRoute + '/api/persistent-chat': typeof ApiPersistentChatRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/persistent-chat': typeof PersistentChatRoute + '/api/persistent-chat': typeof ApiPersistentChatRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/persistent-chat': typeof PersistentChatRoute + '/api/persistent-chat': typeof ApiPersistentChatRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/persistent-chat' | '/api/persistent-chat' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/persistent-chat' | '/api/persistent-chat' + id: '__root__' | '/' | '/persistent-chat' | '/api/persistent-chat' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + PersistentChatRoute: typeof PersistentChatRoute + ApiPersistentChatRoute: typeof ApiPersistentChatRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/persistent-chat': { + id: '/persistent-chat' + path: '/persistent-chat' + fullPath: '/persistent-chat' + preLoaderRoute: typeof PersistentChatRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/api/persistent-chat': { + id: '/api/persistent-chat' + path: '/api/persistent-chat' + fullPath: '/api/persistent-chat' + preLoaderRoute: typeof ApiPersistentChatRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + PersistentChatRoute: PersistentChatRoute, + ApiPersistentChatRoute: ApiPersistentChatRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/examples/persistent-chat-prisma/src/router.tsx b/examples/persistent-chat-prisma/src/router.tsx new file mode 100644 index 000000000..ee1edab88 --- /dev/null +++ b/examples/persistent-chat-prisma/src/router.tsx @@ -0,0 +1,13 @@ +import { createRouter } from '@tanstack/react-router' + +// Import the generated route tree +import { routeTree } from './routeTree.gen' + +// Create a new router instance +export const getRouter = () => { + return createRouter({ + routeTree, + scrollRestoration: true, + defaultPreloadStaleTime: 0, + }) +} diff --git a/examples/persistent-chat-prisma/src/routes/__root.tsx b/examples/persistent-chat-prisma/src/routes/__root.tsx new file mode 100644 index 000000000..f8d080df6 --- /dev/null +++ b/examples/persistent-chat-prisma/src/routes/__root.tsx @@ -0,0 +1,26 @@ +import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + { title: 'Persistent Chat — Prisma 7 (SQLite)' }, + ], + }), + shellComponent: RootDocument, +}) + +function RootDocument({ children }: { children: React.ReactNode }) { + return ( + + + + + + {children} + + + + ) +} diff --git a/examples/persistent-chat-prisma/src/routes/api.persistent-chat.ts b/examples/persistent-chat-prisma/src/routes/api.persistent-chat.ts new file mode 100644 index 000000000..15621d9b7 --- /dev/null +++ b/examples/persistent-chat-prisma/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: { store, messages: false }`) + * 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 `{ messages: false }`) 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/persistent-chat-prisma/src/routes/index.tsx b/examples/persistent-chat-prisma/src/routes/index.tsx new file mode 100644 index 000000000..e7c0e55f5 --- /dev/null +++ b/examples/persistent-chat-prisma/src/routes/index.tsx @@ -0,0 +1,8 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' + +// The demo lives at /persistent-chat; send the root there. +export const Route = createFileRoute('/')({ + beforeLoad: () => { + throw redirect({ to: '/persistent-chat' }) + }, +}) diff --git a/examples/persistent-chat-prisma/src/routes/persistent-chat.css b/examples/persistent-chat-prisma/src/routes/persistent-chat.css new file mode 100644 index 000000000..427df1a7d --- /dev/null +++ b/examples/persistent-chat-prisma/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/persistent-chat-prisma/src/routes/persistent-chat.tsx b/examples/persistent-chat-prisma/src/routes/persistent-chat.tsx new file mode 100644 index 000000000..8a651fa9c --- /dev/null +++ b/examples/persistent-chat-prisma/src/routes/persistent-chat.tsx @@ -0,0 +1,331 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useEffect, useRef, useState } from 'react' +import { + fetchServerSentEvents, + localStoragePersistence, +} from '@tanstack/ai-client' +import { useChat } from '@tanstack/ai-react' +import { 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 only resume pointers, +// never transcripts. 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 = { store: localStoragePersistence(), messages: false } + +// 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 + only a resume pointer (messages: false); 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/persistent-chat-prisma/tsconfig.json b/examples/persistent-chat-prisma/tsconfig.json new file mode 100644 index 000000000..4327646a3 --- /dev/null +++ b/examples/persistent-chat-prisma/tsconfig.json @@ -0,0 +1,20 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "target": "ES2022", + "jsx": "react-jsx", + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client", "node"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": false, + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + } +} diff --git a/examples/persistent-chat-prisma/vite.config.ts b/examples/persistent-chat-prisma/vite.config.ts new file mode 100644 index 000000000..3484f1303 --- /dev/null +++ b/examples/persistent-chat-prisma/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import viteReact from '@vitejs/plugin-react' +import { nitro } from 'nitro/vite' + +// The Prisma client + better-sqlite3 driver adapter are server-only and load a +// native addon; keep them out of the client optimizer and resolved at runtime +// on the server rather than inlined into the SSR/client bundle. +const SERVER_ONLY = [ + '@prisma/client', + '@prisma/adapter-better-sqlite3', + '@tanstack/ai-persistence-prisma', +] + +export default defineConfig({ + optimizeDeps: { exclude: SERVER_ONLY }, + ssr: { external: SERVER_ONLY }, + build: { rollupOptions: { external: SERVER_ONLY } }, + plugins: [nitro(), tanstackStart(), viteReact()], +}) diff --git a/examples/ts-react-chat/.gitignore b/examples/ts-react-chat/.gitignore index 029f7fba9..620400002 100644 --- a/examples/ts-react-chat/.gitignore +++ b/examples/ts-react-chat/.gitignore @@ -10,3 +10,4 @@ count.txt .output .vinxi todos.json +.data diff --git a/examples/ts-react-chat/README.md b/examples/ts-react-chat/README.md index 1a07c1533..2ccf972c2 100644 --- a/examples/ts-react-chat/README.md +++ b/examples/ts-react-chat/README.md @@ -369,6 +369,18 @@ Files prefixed with `demo` can be safely deleted. They are there to provide a st You can learn more about all of the offerings from TanStack in the [TanStack documentation](https://tanstack.com). +## Persistent chat (`/persistent-chat`) + +A chat that survives a full page reload on both ends. The client writes the +transcript to `localStorage` (via `localStoragePersistence`), so a reload +restores the conversation instantly. The server writes the same transcript, +run records, and interrupt state to SQLite with `withPersistence` +(`@tanstack/ai-persistence` + the Drizzle SQLite backend), so it survives a +server restart too — the DB lives at `.data/persistent-chat.db` (gitignored). + +Try it: send a message, wait for the reply, then reload the page. The +conversation is still there. Needs `OPENAI_API_KEY` in `.env`. + ## Sandboxes — GitHub issue triage (`/sandboxes`) Pick a harness adapter (Claude Code, Codex, OpenCode) and a sandbox diff --git a/examples/ts-react-chat/package.json b/examples/ts-react-chat/package.json index 94ee33bd0..986424f43 100644 --- a/examples/ts-react-chat/package.json +++ b/examples/ts-react-chat/package.json @@ -36,6 +36,8 @@ "@tanstack/ai-openai": "workspace:*", "@tanstack/ai-opencode": "workspace:*", "@tanstack/ai-openrouter": "workspace:*", + "@tanstack/ai-persistence": "workspace:*", + "@tanstack/ai-persistence-drizzle": "workspace:*", "@tanstack/ai-react": "workspace:*", "@tanstack/ai-react-ui": "workspace:*", "@tanstack/ai-sandbox": "workspace:*", diff --git a/examples/ts-react-chat/src/components/Header.tsx b/examples/ts-react-chat/src/components/Header.tsx index 62ebc7939..536495866 100644 --- a/examples/ts-react-chat/src/components/Header.tsx +++ b/examples/ts-react-chat/src/components/Header.tsx @@ -6,6 +6,7 @@ import { BadgeCheck, Braces, Code2, + Database, FileAudio, FileText, Guitar, @@ -313,6 +314,19 @@ export default function Header() { Resumable Streams + setIsOpen(false)} + className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2" + activeProps={{ + className: + 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2', + }} + > + + Persistent Chat + + setIsOpen(false)} diff --git a/examples/ts-react-chat/src/lib/persistent-chat-store.ts b/examples/ts-react-chat/src/lib/persistent-chat-store.ts new file mode 100644 index 000000000..29549f2c8 --- /dev/null +++ b/examples/ts-react-chat/src/lib/persistent-chat-store.ts @@ -0,0 +1,24 @@ +import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite' + +/** Stable thread id for the single-conversation demo. */ +export const PERSISTENT_CHAT_THREAD_ID = 'persistent-chat' + +let instance: ReturnType | undefined + +/** + * One SQLite-backed persistence store for the persistent-chat demo, shared by + * the API route (POST writes the transcript, GET replays / reconstructs it) and + * the history server function the page loader calls. Lazily opened so importing + * this module (e.g. from a server-fn module that a client route also imports) + * never opens the database in the browser bundle. + * + * Uses the stock default schema and runtime table bootstrap (not a shipped + * migration journal). Production apps should emit a schema with + * `tanstack-ai-drizzle-schema` and migrate via their own drizzle-kit journal. + * `.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/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..15621d9b7 --- /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: { store, messages: false }`) + * 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 `{ messages: false }`) 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..8a651fa9c --- /dev/null +++ b/examples/ts-react-chat/src/routes/persistent-chat.tsx @@ -0,0 +1,331 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useEffect, useRef, useState } from 'react' +import { + fetchServerSentEvents, + localStoragePersistence, +} from '@tanstack/ai-client' +import { useChat } from '@tanstack/ai-react' +import { 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 only resume pointers, +// never transcripts. 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 = { store: localStoragePersistence(), messages: false } + +// 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 + only a resume pointer (messages: false); 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/package.json b/package.json index 701289f79..83a93a48f 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "test:pr": "pnpm run test:react-native && nx affected --targets=test:sherif,test:knip,test:docs,test:kiira,test:oxlint,test:lib,test:types,test:build,build && pnpm test:dts", "test:ci": "pnpm run test:react-native && nx run-many --targets=test:sherif,test:knip,test:docs,test:kiira,test:oxlint,test:lib,test:types,test:build,build && pnpm test:dts", "test:oxlint": "nx affected --target=test:oxlint --exclude=examples/**,testing/**", - "test:sherif": "sherif --ignore-dependency typescript --ignore-dependency vite", + "test:sherif": "sherif --ignore-dependency typescript --ignore-dependency vite --ignore-dependency prisma --ignore-dependency @prisma/client", "test:lib": "nx affected --targets=test:lib --exclude=examples/**,testing/**", "test:lib:dev": "pnpm test:lib && nx watch --all -- pnpm test:lib", "test:coverage": "nx affected --targets=test:coverage --exclude=examples/**,testing/**", diff --git a/packages/ai-angular/src/index.ts b/packages/ai-angular/src/index.ts index 4f9565c58..1752b05d5 100644 --- a/packages/ai-angular/src/index.ts +++ b/packages/ai-angular/src/index.ts @@ -77,6 +77,17 @@ export type { // Re-export from @tanstack/ai-client for convenience export { fetchServerSentEvents, + localStoragePersistence, + sessionStoragePersistence, + indexedDBPersistence, + StorageUnavailableError, + type ChatClientPersistence, + type ChatPersistedState, + type ChatPersistenceConfig, + type ChatPersistenceOption, + type ChatStorageAdapter, + type WebStoragePersistenceOptions, + type IndexedDBPersistenceOptions, fetchHttpStream, xhrServerSentEvents, xhrHttpStream, diff --git a/packages/ai-client/src/chat-client.ts b/packages/ai-client/src/chat-client.ts index c55cfd7b4..543877489 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,16 @@ 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 + // Whether this client caches the transcript (client-authoritative). In + // `messages: false` (server-authoritative) mode it is false: the client holds + // no transcript and no cached run pointer, and re-hydrates from the server on + // mount keyed by threadId (see `hydrateFromServer`). Assigned once in the + // constructor from the resolved `persistence` option. + private readonly cachesMessages: boolean + // 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,15 +403,42 @@ export class ChatClient< } constructor(options: ChatClientOptions) { - this.uniqueId = options.id || this.generateUniqueId('chat') this.threadId = options.threadId || this.generateUniqueId('thread') + // 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 + // Whether the persistor caches the transcript. In `{ messages: false }` + // mode it does not, so the persisted record's empty transcript must never + // override host-provided `initialMessages` (history the app loaded from the + // server); see the initialMessages resolution below. + let cachesMessages = true if (options.persistence) { + // The `persistence` option is either a bare adapter (store everything) or + // `{ store, messages }`. `messages: false` caches only the resume pointer, + // leaving the transcript off the client (server-authoritative history). + const store = + 'store' in options.persistence + ? options.persistence.store + : options.persistence + cachesMessages = + 'store' in options.persistence + ? options.persistence.messages !== false + : true + // Persistence keys on `threadId` (the conversation identity) so a reload + // with the same `threadId` finds the same record. `id` overrides it only + // when set, for apps that key storage separately from the wire thread. + const persistenceKey = options.id ?? this.threadId this.persistor = new ChatPersistor( - options.persistence, - this.uniqueId, + store, + persistenceKey, (messages) => this.processor.setMessages(messages), + (snapshot) => this.applyPersistedResume(snapshot), + cachesMessages, ) } + this.cachesMessages = cachesMessages // Both `body` (deprecated) and `forwardedProps` populate the AG-UI // `RunAgentInput.forwardedProps` wire field. They are stored // separately so `updateOptions` can replace one without touching the @@ -439,10 +501,53 @@ export class ChatClient< // Create StreamProcessor with event handlers. // Use conditional spreads so we don't pass `undefined` into // `StreamProcessorOptions` fields under `exactOptionalPropertyTypes`. - const persistedMessages = this.persistor?.readInitial() - const initialMessages = Array.isArray(persistedMessages) - ? persistedMessages - : options.initialMessages + const persistedState = this.persistor?.readInitial() + const syncPersistedState = + persistedState instanceof Promise ? undefined : persistedState + // Adopt the persisted transcript only when we actually cache it. In + // `messages: false` mode the record's empty `messages` is "not stored here", + // not "the conversation is empty", so fall back to host `initialMessages` + // (e.g. history the app fetched from the server) and take only `resume`. + const initialMessages = + cachesMessages && syncPersistedState + ? syncPersistedState.messages + : options.initialMessages + // A durable snapshot read synchronously from storage wins over the + // in-memory `initialResumeSnapshot` fallback applied above. A snapshot with + // pending interrupts rehydrates the interrupt UI; a bare in-flight run is + // rejoined after the processor is ready (see `rejoinRunId` below). + let rejoinRunId: string | null = null + if (syncPersistedState?.resume) { + const snapshot = syncPersistedState.resume + const hasPendingInterrupts = + Array.isArray(snapshot.pendingInterrupts) && + snapshot.pendingInterrupts.length > 0 + if (hasPendingInterrupts) { + // Interrupts are restored in BOTH modes — they are run-scoped state, not + // a reconnect cursor. + this.applyResumeSnapshot(snapshot) + } else if (snapshot.resumeState.runId && cachesMessages) { + // A bare in-flight run pointer only drives rejoin in client-authoritative + // mode. In `messages: false`, reconnect is resolved from the server by + // threadId (`hydrateFromServer`), so a cached run id is never trusted. + 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 +761,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 (messages:false): 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 +804,86 @@ 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 + // Bare in-flight rejoin from a cached pointer is client-authoritative only; + // `messages: false` reconnect is server-resolved by threadId (see + // `hydrateFromServer`). Interrupts (above) restore in both modes. + if (!hasInterrupts && runId && this.cachesMessages) { + 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 (`messages: false`). 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.activeRun?.runId) { + this.maybeRejoinInFlight(result.activeRun.runId) + } else if (result.interrupts && result.interrupts.pending.length > 0) { + // The run paused on an interrupt (not running, so no tail). Restore the + // pending 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 what makes interrupts + // server-authoritative, not dependent on client storage. + this.applyResumeSnapshot({ + schemaVersion: 2, + resumeState: { + threadId: this.threadId, + runId: result.interrupts.runId, + }, + pendingInterrupts: result.interrupts.pending, + }) + } + })() + } + mountDevtools(): void { if (this.devtoolsMounted) { return @@ -731,6 +933,23 @@ 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. CLIENT-AUTHORITATIVE only: in + // `messages: false` mode the server owns reconnect (resolved from threadId + // via `hydrateFromServer` on mount), so a client-cached run pointer — which + // goes stale the moment a turn spans a second run — must never be written. + // Interrupt/terminal handling overwrites or clears it in observeInterruptState. + if ( + this.persistor && + this.connection.joinRun && + !this.lastResume && + this.cachesMessages + ) { + this.persistResumeSnapshot({ + threadId: this.activeResumeThreadId ?? this.threadId, + runId: chunkRunId, + }) + } return } @@ -828,6 +1047,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 +1246,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 +1259,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 +1439,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 +1574,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..c9e6930e0 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,105 @@ export class ChatPersistor { private readonly adapter: ChatClientPersistence, private readonly id: string, private readonly applyMessages: (messages: Array) => void, + private readonly applyResume?: (snapshot: ChatResumeSnapshot) => void, + // When false, the transcript is never cached client-side; only the tiny + // resume pointer is persisted (server-authoritative history). Defaults true. + private readonly storeMessages: boolean = true, ) {} + /** + * Persist the current state as one combined record. When `storeMessages` is + * false the transcript is omitted (empty), so only the resume pointer is + * written and large histories never hit client storage. + */ + private writeState(): void { + const messages = this.storeMessages ? [...this.lastMessages] : [] + // Nothing to persist (no transcript, no resume pointer): remove the key + // rather than writing an empty `{ messages: [] }`. Critical for + // `storeMessages: false` mode, where clearing the resume pointer must + // drop the prior resume-only record so a reload does not re-rejoin a + // finished run. + 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 + // Only apply the persisted transcript when we cache it. In + // `messages: false` mode the record's empty `messages` must not wipe + // host-provided initialMessages; still apply the resume snapshot. + if (this.storeMessages) { + this.lastMessages = state.messages + this.applyMessages(state.messages) + } + if (state.resume && this.applyResume) { + this.applyResume(state.resume) } }) .catch(() => { @@ -116,28 +190,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..cca4e4be6 100644 --- a/packages/ai-client/src/index.ts +++ b/packages/ai-client/src/index.ts @@ -28,6 +28,10 @@ export type { StructuredOutputPart, // Client configuration types ChatClientPersistence, + ChatPersistedState, + ChatPersistenceConfig, + ChatPersistenceOption, + ChatStorageAdapter, ChatClientOptions, ChatPendingInterrupt, BoundInterruptBase, @@ -84,6 +88,17 @@ export type { export { GENERATION_EVENTS } from './generation-types' export { UnsupportedResponseStreamError } from './response-stream' export { clientTools, createChatClientOptions } from './types' +// Web storage adapters for durable chat persistence (messages + resume snapshot) +export { + localStoragePersistence, + sessionStoragePersistence, + indexedDBPersistence, + StorageUnavailableError, +} from './storage-adapters' +export type { + WebStoragePersistenceOptions, + IndexedDBPersistenceOptions, +} from './storage-adapters' export { createAIDevtoolsGenerationPreview, type AIDevtoolsClientMetadata, diff --git a/packages/ai-client/src/storage-adapters.ts b/packages/ai-client/src/storage-adapters.ts new file mode 100644 index 000000000..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..d7dc53bea 100644 --- a/packages/ai-client/src/types.ts +++ b/packages/ai-client/src/types.ts @@ -553,23 +553,91 @@ export interface UIMessage< createdAt?: Date } +/** + * A generic key/value storage adapter. `getItem` may be sync or async; the + * chat persistence layer treats every call as best-effort. The provided + * `localStoragePersistence` / `sessionStoragePersistence` / `indexedDBPersistence` + * factories return one of these, and `ChatStorageAdapter` + * is assignable to {@link ChatClientPersistence}. + */ +export interface ChatStorageAdapter { + getItem: ( + id: string, + ) => TValue | null | undefined | Promise + setItem: (id: string, value: TValue) => void | Promise + removeItem: (id: string) => void | Promise +} + +/** + * The single record a `ChatClientPersistence` adapter stores per chat. It folds + * the two things that must survive a full page reload into one blob under one + * key: the message transcript and the optional resume snapshot (which run to + * rejoin / which interrupts to rehydrate). One adapter, one key — see + * {@link ChatClientPersistence}. + */ +export interface ChatPersistedState< + TTools extends ReadonlyArray = any, +> { + messages: Array> + /** Present while a run is in flight or paused on an interrupt; absent otherwise. */ + resume?: ChatResumeSnapshot +} + +/** + * Storage adapter for durable chat state. A single adapter persists both the + * message transcript and the resume snapshot as one {@link ChatPersistedState} + * record, so a full page reload restores the conversation AND can rejoin an + * in-flight run / rehydrate pending interrupts. + * + * For backward compatibility `getItem` may also return a bare `UIMessage[]` + * (the legacy messages-only format); the client normalizes it to + * `{ messages }`. `setItem` always writes the combined record. + */ export interface ChatClientPersistence< TTools extends ReadonlyArray = any, > { getItem: ( id: string, ) => + | ChatPersistedState | Array> | null | undefined - | Promise> | null | undefined> + | Promise< + ChatPersistedState | Array> | null | undefined + > setItem: ( id: string, - messages: Array>, + state: ChatPersistedState, ) => void | Promise removeItem: (id: string) => void | Promise } +/** + * Persistence configuration for the `persistence` option. Pass a bare + * {@link ChatClientPersistence} adapter to persist everything (the transcript + * plus the resume pointer), or this object form to pull the message lever. + * + * `messages: false` keeps the transcript OFF the client and persists only the + * tiny resume pointer (which run to rejoin, which interrupts are pending), so + * durability rejoin and interrupt restore still work while the server stays the + * authoritative source of history. Big conversations then never hit + * localStorage, avoiding the quota and parse cost. Defaults to `true` + * (transcript cached client-side). + */ +export interface ChatPersistenceConfig< + TTools extends ReadonlyArray = any, +> { + store: ChatClientPersistence + /** Cache the transcript client-side. Default `true`. `false` = resume pointer only. */ + messages?: boolean +} + +/** The `persistence` option: a bare adapter, or `{ store, messages? }`. */ +export type ChatPersistenceOption< + TTools extends ReadonlyArray = any, +> = ChatClientPersistence | ChatPersistenceConfig + type IsUnknown = unknown extends T ? [T] extends [unknown] ? true @@ -661,21 +729,37 @@ export interface ChatClientBaseOptions< initialMessages?: Array> /** - * Optional persistence adapter for chat messages (UIMessage[] by chat id). - * Durable interrupt resume storage is not part of this surface — use - * `initialResumeSnapshot` for in-memory rehydrate after a host-managed load. + * Optional persistence for durable chat state, keyed by chat id. Pass a + * {@link ChatClientPersistence} adapter to store the combined + * {@link ChatPersistedState} record (messages + resume pointer), so a full + * page reload restores the transcript, rehydrates pending interrupts, and + * rejoins an in-flight run. + * + * To keep large transcripts off the client, pass `{ store, messages: false }` + * ({@link ChatPersistenceConfig}) instead: only the tiny resume pointer is + * cached, durability rejoin and interrupt restore still work, and the server + * is the authoritative source of history. Use `initialResumeSnapshot` for a + * host-supplied in-memory rehydrate instead. */ - persistence?: ChatClientPersistence + persistence?: ChatPersistenceOption /** - * Unique identifier for this chat instance - * Used for managing multiple chats + * Optional storage-key override for this chat instance, and the devtools + * instance id. Persistence keys on `threadId` by default; set `id` only when + * you need the persisted record keyed separately from the wire thread. + * Prefer a stable `threadId` for the common case. + * + * 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..57c7e372c --- /dev/null +++ b/packages/ai-client/tests/resume-snapshot.test.ts @@ -0,0 +1,764 @@ +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() + }) + + it('with storeMessages=false persists only the resume pointer', () => { + const { adapter, read } = memoryAdapter() + // storeMessages=false via the 5th constructor arg. + const persistor = new ChatPersistor( + adapter, + 'chat-1', + () => {}, + undefined, + false, + ) + persistor.notifyMessagesChanged([createUIMessage('m1', 'heavy history')]) + persistor.persistResumeSnapshot({ + schemaVersion: 2, + resumeState: { threadId: 't1', runId: 'r1' }, + }) + const stored = read() as ChatPersistedState + // Transcript stays off the client; the tiny resume pointer is kept so + // durability rejoin still works. + expect(stored.messages).toEqual([]) + expect(stored.resume?.resumeState.runId).toBe('r1') + }) + + it('with storeMessages=false removes the key when resume is cleared', () => { + const { adapter, read } = memoryAdapter() + const persistor = new ChatPersistor( + adapter, + 'chat-1', + () => {}, + undefined, + false, + ) + persistor.persistResumeSnapshot({ + schemaVersion: 2, + resumeState: { threadId: 't1', runId: 'r1' }, + }) + expect(read()).toBeDefined() + // Finish / dead-pointer cleanup: clearing resume must drop the key so a + // reload does not re-rejoin a finished run. + persistor.persistResumeSnapshot(null) + expect(read()).toBeUndefined() + }) +}) + +describe('ChatClient persistence option shapes', () => { + it('accepts { store, messages: false } and keeps messages off the client', () => { + const { adapter, read } = memoryAdapter() + const client = new ChatClient({ + id: 'chat-cfg', + threadId: 't1', + connection: { connect: async function* () {} }, + persistence: { store: adapter, messages: false }, + initialMessages: [createUIMessage('seed', 'hi', 'user')], + }) + // A message change should not write the transcript into the record. + void client + const stored = read() + if (stored && !Array.isArray(stored)) { + expect(stored.messages).toEqual([]) + } + }) +}) + +describe('localStoragePersistence ergonomics', () => { + it('needs no type arg or codec and round-trips a ChatPersistedState', () => { + // Minimal in-memory Storage stub so the test doesn't depend on a DOM env. + const map = new Map() + const stub = { + getItem: (k: string) => map.get(k) ?? null, + setItem: (k: string, v: string) => void map.set(k, v), + removeItem: (k: string) => void map.delete(k), + } + const globals = globalThis as { localStorage?: unknown } + const previous = globals.localStorage + globals.localStorage = stub + try { + // The headline call: no generic, no serialize/deserialize. + const store = localStoragePersistence() + const record: ChatPersistedState = { + messages: [createUIMessage('m1', 'hi')], + resume: { + schemaVersion: 2, + resumeState: { threadId: 't1', runId: 'r1' }, + }, + } + store.setItem('chat-1', record) + const read = store.getItem('chat-1') + expect(read && !(read instanceof Promise) && read.messages[0]?.id).toBe( + 'm1', + ) + } finally { + globals.localStorage = previous + } + }) +}) + +describe('normalizeConnectionAdapter joinRun passthrough', () => { + it('exposes joinRun when the connection is resumable', () => { + const joinRun = vi.fn(async function* () { + // empty + }) + const resumable: ResumableConnectConnectionAdapter = { + connect: async function* () {}, + joinRun, + } + const normalized = normalizeConnectionAdapter(resumable) + expect(typeof normalized.joinRun).toBe('function') + }) + + it('omits joinRun for a plain connect adapter', () => { + const normalized = normalizeConnectionAdapter({ + connect: async function* () {}, + }) + expect(normalized.joinRun).toBeUndefined() + }) + + 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('messages:false 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 { adapter } = memoryAdapter({ messages: [] }) + + 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: { store: adapter, messages: false }, + 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('messages:false hydrates a transcript with no active run (no tail)', async () => { + const { adapter } = memoryAdapter({ messages: [] }) + 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: { store: adapter, messages: false }, + 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('messages:false 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 { adapter } = memoryAdapter({ messages: [] }) + 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: { store: adapter, messages: false }, + }) + 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('messages:false persists no bare run pointer (server owns reconnect)', async () => { + // The stale-run trap: a client-cached run id goes stale the moment a turn + // spans a second run. In messages:false the client must persist NO bare run + // pointer — reconnect is resolved from the server by threadId. Use an + // in-flight run (no terminal) so a wrongly-written pointer would still be present. + const { adapter, read } = memoryAdapter({ messages: [] }) + const joinRun = vi.fn(async function* (_runId: string) { + yield { + type: 'RUN_STARTED', + runId: 'provider-run', + threadId: 't1', + timestamp: 1, + } as StreamChunk + yield { + type: 'TEXT_MESSAGE_START', + messageId: 'a1', + role: 'assistant', + timestamp: 2, + } as StreamChunk + yield { + type: 'TEXT_MESSAGE_CONTENT', + messageId: 'a1', + delta: 'partial', + content: 'partial', + timestamp: 3, + } as StreamChunk + }) + const connection: ResumableConnectConnectionAdapter = { + connect: async function* () {}, + joinRun, + hydrate: () => + Promise.resolve({ + messages: [], + activeRun: { runId: 'client-run' }, + interrupts: null, + }), + } + let latest: Array = [] + const client = new ChatClient({ + threadId: 't1', + connection, + persistence: { store: adapter, messages: false }, + onMessagesChange: (m) => { + latest = m + }, + }) + + await vi.waitFor(() => { + const a = latest.find((m) => m.role === 'assistant') + const t = a?.parts.find((p) => p.type === 'text') + expect(t && 'content' in t && t.content).toBe('partial') + }) + + const stored = read() as ChatPersistedState | undefined + expect(stored?.resume).toBeUndefined() + void client + }) + + 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 { adapter } = memoryAdapter({ messages: [] }) + + 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: { store: adapter, messages: false }, + 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 messages:false a dead server-tail frees the input and persists nothing', async () => { + // Server hydration reports an active run, but its durability log is gone — + // joinRun never attaches. The client must free the input (isLoading false) + // and, being server-authoritative, persist no pointer of its own. + const { adapter, read } = memoryAdapter({ messages: [] }) + 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: { store: adapter, messages: false }, + }) + + await vi.waitFor( + () => { + expect(joinRun).toHaveBeenCalled() + expect(client.getIsLoading()).toBe(false) + expect(read()).toBeUndefined() + }, + { 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-persistence-cloudflare/package.json b/packages/ai-persistence-cloudflare/package.json new file mode 100644 index 000000000..b2a2cfcf3 --- /dev/null +++ b/packages/ai-persistence-cloudflare/package.json @@ -0,0 +1,59 @@ +{ + "name": "@tanstack/ai-persistence-cloudflare", + "version": "0.0.0", + "description": "Cloudflare D1 state (via Drizzle) and Durable Object locks for TanStack AI.", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-persistence-cloudflare" + }, + "keywords": [ + "ai", + "tanstack", + "persistence", + "cloudflare", + "d1", + "durable-objects" + ], + "type": "module", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "vite build", + "clean": "premove ./build ./dist", + "lint:fix": "oxlint src --type-aware --fix", + "test:build": "publint --strict", + "test:lib": "vitest", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc", + "test:oxlint": "oxlint src --type-aware" + }, + "peerDependencies": { + "@cloudflare/workers-types": ">=4.20260317.1", + "@tanstack/ai": "workspace:*", + "@tanstack/ai-persistence": "workspace:*", + "@tanstack/ai-persistence-drizzle": "workspace:*", + "drizzle-orm": ">=0.44.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@tanstack/ai": "workspace:*", + "@tanstack/ai-persistence": "workspace:*", + "@tanstack/ai-persistence-drizzle": "workspace:*", + "@vitest/coverage-v8": "4.0.14", + "drizzle-orm": "^0.45.0", + "miniflare": "^4.20260609.0" + } +} diff --git a/packages/ai-persistence-cloudflare/src/bindings.ts b/packages/ai-persistence-cloudflare/src/bindings.ts new file mode 100644 index 000000000..cdf01afac --- /dev/null +++ b/packages/ai-persistence-cloudflare/src/bindings.ts @@ -0,0 +1,24 @@ +export interface DurableObjectStubBinding { + fetch: ( + input: Request | string | URL, + init?: RequestInit, + ) => Promise +} + +/** The Durable Object namespace surface used by the lock client. */ +export interface DurableObjectNamespaceBinding { + idFromName: (name: string) => TId + get: (id: TId) => DurableObjectStubBinding +} + +export interface LockDurableObjectStorage { + get: (key: string) => Promise + put: (key: string, value: unknown) => Promise + delete: (key: string) => Promise + setAlarm: (timestamp: number) => Promise + deleteAlarm: () => Promise +} + +export interface LockDurableObjectState { + storage: LockDurableObjectStorage +} diff --git a/packages/ai-persistence-cloudflare/src/d1.ts b/packages/ai-persistence-cloudflare/src/d1.ts new file mode 100644 index 000000000..3bd00c55c --- /dev/null +++ b/packages/ai-persistence-cloudflare/src/d1.ts @@ -0,0 +1,28 @@ +import { drizzle } from 'drizzle-orm/d1' +import { + createDefaultSqliteSchema, + drizzlePersistence, +} from '@tanstack/ai-persistence-drizzle' + +/** + * Create the structured stores over a Cloudflare D1 binding. + * + * Thin wrapper: stock SQLite schema + `drizzle-orm/d1` + + * {@link drizzlePersistence}. This package does **not** ship or apply DDL — + * migrate tables with your own drizzle-kit journal (or equivalent SQL matching + * the default schema) before use. For custom table names/columns, own a schema + * from `tanstack-ai-drizzle-schema` and call `drizzlePersistence` yourself. + */ +export function createD1Stores(d1: D1Database) { + const schema = createDefaultSqliteSchema() + const persistence = drizzlePersistence(drizzle(d1, { schema }), { + provider: 'sqlite', + schema, + }) + return { + messages: persistence.stores.messages, + runs: persistence.stores.runs, + interrupts: persistence.stores.interrupts, + metadata: persistence.stores.metadata, + } +} diff --git a/packages/ai-persistence-cloudflare/src/index.ts b/packages/ai-persistence-cloudflare/src/index.ts new file mode 100644 index 000000000..b90b06ba5 --- /dev/null +++ b/packages/ai-persistence-cloudflare/src/index.ts @@ -0,0 +1,49 @@ +import { createD1Stores } from './d1' +import type { ChatPersistence } from '@tanstack/ai-persistence' + +export { createD1Stores } from './d1' +export { + CloudflareLockDurableObject, + createDurableObjectLockStore, +} from './locks' +export type { DurableObjectLockStoreOptions } from './locks' +export type { + DurableObjectNamespaceBinding, + DurableObjectStubBinding, + LockDurableObjectState, + LockDurableObjectStorage, +} from './bindings' + +/** + * D1-backed **chat** state stores (`messages`, `runs`, `interrupts`, `metadata`). + * + * Thin convenience over `@tanstack/ai-persistence-drizzle` + `drizzle-orm/d1`. + * Schema DDL is **not** owned here — emit a SQLite schema with + * `tanstack-ai-drizzle-schema` (or re-export + * `@tanstack/ai-persistence-drizzle/sqlite-schema`), migrate with your + * drizzle-kit journal / Wrangler `migrations_dir`, then bind D1. + * + * Locks are a separate concern: use {@link createDurableObjectLockStore} with + * `withLocks` from `@tanstack/ai-persistence` when you need multi-instance + * coordination. + */ +export interface CloudflarePersistenceOptions { + d1: D1Database +} + +/** + * Wire TanStack AI chat persistence over a migrated D1 binding. + * + * Returns {@link ChatPersistence} using the stock SQLite schema from + * `@tanstack/ai-persistence-drizzle`. Prefer owning that schema in your app + * and calling `drizzlePersistence` directly when you need renames or extra + * columns. Durable Object locks are separate via + * {@link createDurableObjectLockStore}. + */ +export function cloudflarePersistence( + options: CloudflarePersistenceOptions, +): ChatPersistence { + return { + stores: createD1Stores(options.d1), + } +} diff --git a/packages/ai-persistence-cloudflare/src/locks.ts b/packages/ai-persistence-cloudflare/src/locks.ts new file mode 100644 index 000000000..1b941dcf4 --- /dev/null +++ b/packages/ai-persistence-cloudflare/src/locks.ts @@ -0,0 +1,376 @@ +import type { LockStore } from '@tanstack/ai-persistence' +import type { + DurableObjectNamespaceBinding, + DurableObjectStubBinding, + LockDurableObjectState, +} from './bindings' + +interface LeaseRecord { + ownerId: string + expiresAt: number +} + +interface LeaseRequest { + ownerId: string + leaseDurationMs: number +} + +export interface DurableObjectLockStoreOptions { + /** Duration of each lock lease. Defaults to 30 seconds. */ + leaseDurationMs?: number + /** Renewal cadence. Defaults to one third of the lease duration. */ + renewIntervalMs?: number + /** Maximum time spent retrying a contended acquire. Defaults to 30 seconds. */ + acquireTimeoutMs?: number + /** Delay between contended acquire attempts. Defaults to 50 milliseconds. */ + retryDelayMs?: number + /** Override owner ID generation, primarily for deterministic runtimes/tests. */ + createOwnerId?: () => string +} + +interface ResolvedLockOptions { + leaseDurationMs: number + renewIntervalMs: number + acquireTimeoutMs: number + retryDelayMs: number + createOwnerId: () => string +} + +type SettledValue = + | { status: 'fulfilled'; value: T } + | { status: 'rejected'; reason: unknown } + +const leaseKey = 'lease' + +function isLeaseRecord(value: unknown): value is LeaseRecord { + return ( + value !== null && + typeof value === 'object' && + 'ownerId' in value && + typeof value.ownerId === 'string' && + 'expiresAt' in value && + typeof value.expiresAt === 'number' + ) +} + +function isLeaseRequest(value: unknown): value is LeaseRequest { + return ( + value !== null && + typeof value === 'object' && + 'ownerId' in value && + typeof value.ownerId === 'string' && + value.ownerId.length > 0 && + 'leaseDurationMs' in value && + typeof value.leaseDurationMs === 'number' && + Number.isFinite(value.leaseDurationMs) && + value.leaseDurationMs > 0 + ) +} + +function response(status: number): Response { + return new Response(null, { status }) +} + +/** + * Durable Object class backing distributed lock leases. + * + * Bind this class in Wrangler and pass the resulting namespace to + * `createDurableObjectLockStore` + `withLocks`. + * + * SECURITY: this DO must ONLY be reachable through its namespace binding (via + * `createDurableObjectLockStore`). Its `fetch` handler trusts the caller's + * `ownerId` and performs no authentication, so routing public HTTP straight to + * this class exposes an unauthenticated lock-manipulation surface — anyone + * could acquire, renew, or release any lock key. Never wire it into a public + * Worker route; reach it only by `namespace.get(namespace.idFromName(key))`. + */ +export class CloudflareLockDurableObject { + private operationChain: Promise = Promise.resolve() + + constructor(private readonly state: LockDurableObjectState) {} + + fetch(request: Request): Promise { + return this.serialize(() => this.handleRequest(request)) + } + + alarm(): Promise { + return this.serialize(async () => { + const lease = await this.readLease() + if (!lease) { + await this.state.storage.deleteAlarm() + return + } + if (lease.expiresAt > Date.now()) { + await this.state.storage.setAlarm(lease.expiresAt) + return + } + await this.state.storage.delete(leaseKey) + await this.state.storage.deleteAlarm() + }) + } + + private async handleRequest(request: Request): Promise { + if (request.method !== 'POST') return response(405) + let input: unknown + try { + input = await request.json() + } catch { + return response(400) + } + if (!isLeaseRequest(input)) return response(400) + + const operation = new URL(request.url).pathname + if (operation === '/acquire') return this.acquire(input) + if (operation === '/renew') return this.renew(input) + if (operation === '/release') return this.release(input.ownerId) + return response(404) + } + + private async acquire(input: LeaseRequest): Promise { + const current = await this.readLease() + if (current && current.expiresAt > Date.now()) return response(409) + await this.writeLease(input) + return response(200) + } + + private async renew(input: LeaseRequest): Promise { + const current = await this.readLease() + if ( + !current || + current.ownerId !== input.ownerId || + current.expiresAt <= Date.now() + ) { + return response(409) + } + await this.writeLease(input) + return response(200) + } + + private async release(ownerId: string): Promise { + const current = await this.readLease() + if (!current || current.ownerId !== ownerId) return response(409) + await this.state.storage.delete(leaseKey) + await this.state.storage.deleteAlarm() + return response(200) + } + + private async readLease(): Promise { + const value = await this.state.storage.get(leaseKey) + if (value === undefined) return undefined + if (!isLeaseRecord(value)) { + throw new Error('Durable Object lock lease is invalid') + } + return value + } + + private async writeLease(input: LeaseRequest): Promise { + const lease: LeaseRecord = { + ownerId: input.ownerId, + expiresAt: Date.now() + input.leaseDurationMs, + } + await this.state.storage.put(leaseKey, lease) + await this.state.storage.setAlarm(lease.expiresAt) + } + + private serialize(operation: () => Promise): Promise { + const result = this.operationChain.then(operation, operation) + this.operationChain = result.then( + () => undefined, + () => undefined, + ) + return result + } +} + +function resolveOptions( + options: DurableObjectLockStoreOptions, +): ResolvedLockOptions { + const leaseDurationMs = options.leaseDurationMs ?? 30_000 + const renewIntervalMs = options.renewIntervalMs ?? leaseDurationMs / 3 + const acquireTimeoutMs = options.acquireTimeoutMs ?? 30_000 + const retryDelayMs = options.retryDelayMs ?? 50 + for (const [name, value] of [ + ['leaseDurationMs', leaseDurationMs], + ['renewIntervalMs', renewIntervalMs], + ['acquireTimeoutMs', acquireTimeoutMs], + ['retryDelayMs', retryDelayMs], + ] as const) { + if (!Number.isFinite(value) || value <= 0) { + throw new RangeError(`${name} must be a positive finite number`) + } + } + if (renewIntervalMs >= leaseDurationMs) { + throw new RangeError('renewIntervalMs must be less than leaseDurationMs') + } + return { + leaseDurationMs, + renewIntervalMs, + acquireTimeoutMs, + retryDelayMs, + createOwnerId: options.createOwnerId ?? (() => crypto.randomUUID()), + } +} + +function sleep(milliseconds: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal?.aborted) { + resolve() + return + } + const timeout = setTimeout(() => { + signal?.removeEventListener('abort', stopWaiting) + resolve() + }, milliseconds) + const stopWaiting = () => { + clearTimeout(timeout) + resolve() + } + signal?.addEventListener('abort', stopWaiting, { once: true }) + }) +} + +function isAborted(signal: AbortSignal): boolean { + return signal.aborted +} + +async function settle(promise: Promise): Promise> { + try { + return { status: 'fulfilled', value: await promise } + } catch (error) { + return { status: 'rejected', reason: error } + } +} + +async function lockOperation( + stub: DurableObjectStubBinding, + operation: 'acquire' | 'renew' | 'release', + ownerId: string, + leaseDurationMs: number, +): Promise { + return stub.fetch( + new Request(`https://tanstack-ai-lock.invalid/${operation}`, { + method: 'POST', + body: JSON.stringify({ ownerId, leaseDurationMs }), + headers: { 'content-type': 'application/json' }, + }), + ) +} + +function operationError(operation: string, status: number): Error { + return new Error( + `Durable Object lock ${operation} failed with status ${status}`, + ) +} + +async function acquireLease( + stub: DurableObjectStubBinding, + ownerId: string, + options: ResolvedLockOptions, +): Promise { + const deadline = Date.now() + options.acquireTimeoutMs + for (;;) { + const result = await lockOperation( + stub, + 'acquire', + ownerId, + options.leaseDurationMs, + ) + if (result.ok) return + if (result.status !== 409) throw operationError('acquire', result.status) + if (Date.now() >= deadline) { + throw new Error('Durable Object lock acquire timed out') + } + await sleep(options.retryDelayMs) + } +} + +async function renewLeaseUntilFinished( + stub: DurableObjectStubBinding, + ownerId: string, + options: ResolvedLockOptions, + signal: AbortSignal, +): Promise { + for (;;) { + if (isAborted(signal)) return + await sleep(options.renewIntervalMs, signal) + if (isAborted(signal)) return + const result = await lockOperation( + stub, + 'renew', + ownerId, + options.leaseDurationMs, + ) + if (!result.ok) throw operationError('renew', result.status) + } +} + +function throwFailures(failures: Array): void { + if (failures.length === 1) throw failures[0] + if (failures.length > 1) { + throw new AggregateError(failures, 'Durable Object lock operation failed') + } +} + +/** Create a distributed LockStore backed by one Durable Object per lock key. */ +export function createDurableObjectLockStore( + namespace: DurableObjectNamespaceBinding, + lockOptions: DurableObjectLockStoreOptions = {}, +): LockStore { + const options = resolveOptions(lockOptions) + return { + async withLock( + key: string, + fn: (signal: AbortSignal) => Promise, + ): Promise { + const ownerId = options.createOwnerId() + const stub = namespace.get(namespace.idFromName(key)) + await acquireLease(stub, ownerId, options) + + const workFinished = new AbortController() + const leaseOwned = new AbortController() + const workResultPromise = settle( + Promise.resolve().then(() => fn(leaseOwned.signal)), + ) + const renewalResultPromise = settle( + renewLeaseUntilFinished( + stub, + ownerId, + options, + workFinished.signal, + ).catch((error: unknown) => { + leaseOwned.abort(error) + throw error + }), + ) + const workResult = await workResultPromise + workFinished.abort() + const renewalResult = await renewalResultPromise + // A 409 from `release` means the lease was no longer ours (it expired, or + // another owner acquired it) by the time the critical section finished. + // We surface that as a thrown error and let `withLock` reject even though + // the work itself may have completed: a lost lease means mutual exclusion + // could have been violated mid-section, so the caller must NOT treat the + // result as if it ran under the lock. `leaseOwned` is aborted when renewal + // fails, so a well-behaved critical section will already have stopped. + const releaseResult = await settle( + lockOperation(stub, 'release', ownerId, options.leaseDurationMs).then( + (result) => { + if (!result.ok) throw operationError('release', result.status) + }, + ), + ) + + const failures: Array = [] + if (workResult.status === 'rejected') failures.push(workResult.reason) + if (renewalResult.status === 'rejected') { + failures.push(renewalResult.reason) + } + if (releaseResult.status === 'rejected') { + failures.push(releaseResult.reason) + } + throwFailures(failures) + if (workResult.status === 'rejected') throw workResult.reason + return workResult.value + }, + } +} diff --git a/packages/ai-persistence-cloudflare/tests/api-types.test-d.ts b/packages/ai-persistence-cloudflare/tests/api-types.test-d.ts new file mode 100644 index 000000000..2aaf72d80 --- /dev/null +++ b/packages/ai-persistence-cloudflare/tests/api-types.test-d.ts @@ -0,0 +1,58 @@ +/// +import { expectTypeOf } from 'vitest' +import { composePersistence } from '@tanstack/ai-persistence' +import { + CloudflareLockDurableObject, + cloudflarePersistence, + createDurableObjectLockStore, +} from '../src/index' +import type { + ChatPersistence, + InterruptStore, + LockStore, + MessageStore, + MetadataStore, + RunStore, +} from '@tanstack/ai-persistence' + +declare const d1: D1Database +declare const durableObjects: DurableObjectNamespace +declare const durableObjectState: DurableObjectState + +new CloudflareLockDurableObject(durableObjectState) + +expectTypeOf(cloudflarePersistence({ d1 })).toEqualTypeOf() +expectTypeOf(cloudflarePersistence({ d1 }).stores).toMatchTypeOf<{ + messages: MessageStore + runs: RunStore + interrupts?: InterruptStore + metadata?: MetadataStore +}>() +// Packaged backends always provide all four state stores: +expectTypeOf( + cloudflarePersistence({ d1 }).stores.messages, +).toEqualTypeOf() +expectTypeOf( + cloudflarePersistence({ d1 }).stores.runs, +).toEqualTypeOf() + +// Locks are a separate export — not part of the state bag. +expectTypeOf( + createDurableObjectLockStore(durableObjects), +).toEqualTypeOf() + +const d1Persistence = cloudflarePersistence({ d1 }) +declare const customInterrupts: InterruptStore +const replaced = composePersistence(d1Persistence, { + overrides: { interrupts: customInterrupts }, +}) +expectTypeOf(replaced.stores.interrupts).toEqualTypeOf() +expectTypeOf(replaced.stores.runs).toEqualTypeOf() + +const removed = composePersistence(d1Persistence, { + overrides: { interrupts: false }, +}) +expectTypeOf(removed.stores.messages).toEqualTypeOf() +expectTypeOf(removed.stores.runs).toEqualTypeOf() +// @ts-expect-error interrupts removed +removed.stores.interrupts diff --git a/packages/ai-persistence-cloudflare/tests/locks.test.ts b/packages/ai-persistence-cloudflare/tests/locks.test.ts new file mode 100644 index 000000000..c40822089 --- /dev/null +++ b/packages/ai-persistence-cloudflare/tests/locks.test.ts @@ -0,0 +1,359 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + CloudflareLockDurableObject, + createDurableObjectLockStore, +} from '../src/index' +import type { + DurableObjectNamespaceBinding, + DurableObjectStubBinding, + LockDurableObjectStorage, +} from '../src/index' + +class FakeLockStorage implements LockDurableObjectStorage { + readonly values = new Map() + alarm: number | undefined + + get(key: string): Promise { + return Promise.resolve(this.values.get(key)) + } + + put(key: string, value: unknown): Promise { + this.values.set(key, value) + return Promise.resolve() + } + + delete(key: string): Promise { + return Promise.resolve(this.values.delete(key)) + } + + setAlarm(timestamp: number): Promise { + this.alarm = timestamp + return Promise.resolve() + } + + deleteAlarm(): Promise { + this.alarm = undefined + return Promise.resolve() + } +} + +function request( + operation: 'acquire' | 'renew' | 'release', + ownerId: string, + leaseDurationMs = 100, +): Request { + return new Request(`https://lock.invalid/${operation}`, { + method: 'POST', + body: JSON.stringify({ ownerId, leaseDurationMs }), + headers: { 'content-type': 'application/json' }, + }) +} + +function deferred() { + let resolve: (value: T) => void = () => undefined + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve + }) + return { promise, resolve } +} + +function waitForAbort(signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve() + return new Promise((resolve) => { + signal.addEventListener('abort', () => resolve(), { once: true }) + }) +} + +async function ownerIdFrom(request: Request): Promise { + const body: unknown = await request.clone().json() + if ( + typeof body !== 'object' || + body === null || + !('ownerId' in body) || + typeof body.ownerId !== 'string' + ) { + return undefined + } + return body.ownerId +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('CloudflareLockDurableObject', () => { + it('acquires, renews, and releases an owner-scoped lease', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const storage = new FakeLockStorage() + const durableObject = new CloudflareLockDurableObject({ storage }) + + expect((await durableObject.fetch(request('acquire', 'one'))).status).toBe( + 200, + ) + expect((await durableObject.fetch(request('acquire', 'two'))).status).toBe( + 409, + ) + vi.setSystemTime(1_050) + expect((await durableObject.fetch(request('renew', 'one'))).status).toBe( + 200, + ) + expect(storage.alarm).toBe(1_150) + expect((await durableObject.fetch(request('release', 'two'))).status).toBe( + 409, + ) + expect((await durableObject.fetch(request('release', 'one'))).status).toBe( + 200, + ) + expect((await durableObject.fetch(request('acquire', 'two'))).status).toBe( + 200, + ) + }) + + it('expires a lease from the Durable Object alarm', async () => { + vi.useFakeTimers() + vi.setSystemTime(2_000) + const storage = new FakeLockStorage() + const durableObject = new CloudflareLockDurableObject({ storage }) + await durableObject.fetch(request('acquire', 'one')) + + vi.setSystemTime(2_101) + await durableObject.alarm() + + expect((await durableObject.fetch(request('acquire', 'two'))).status).toBe( + 200, + ) + }) +}) + +describe('Durable Object LockStore', () => { + it('renews a long-running critical section and awaits release', async () => { + vi.useFakeTimers() + vi.setSystemTime(3_000) + const storage = new FakeLockStorage() + const server = new CloudflareLockDurableObject({ storage }) + const operations: Array = [] + const stub: DurableObjectStubBinding = { + fetch(input) { + const requestInput = + input instanceof Request ? input : new Request(input) + operations.push(new URL(requestInput.url).pathname) + return server.fetch(requestInput) + }, + } + const namespace: DurableObjectNamespaceBinding = { + idFromName: (name) => name, + get: () => stub, + } + const store = createDurableObjectLockStore(namespace, { + leaseDurationMs: 100, + renewIntervalMs: 40, + retryDelayMs: 5, + acquireTimeoutMs: 100, + createOwnerId: () => 'owner', + }) + const work = deferred() + const result = store.withLock('workspace', () => work.promise) + + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(41) + expect(operations).toContain('/renew') + work.resolve('done') + await expect(result).resolves.toBe('done') + expect(operations.at(-1)).toBe('/release') + }) + + it('surfaces renewal failure after user work settles and still releases', async () => { + vi.useFakeTimers() + const operations: Array = [] + const stub: DurableObjectStubBinding = { + fetch(input) { + const requestInput = + input instanceof Request ? input : new Request(input) + const operation = new URL(requestInput.url).pathname + operations.push(operation) + return Promise.resolve( + new Response(null, { status: operation === '/renew' ? 503 : 200 }), + ) + }, + } + const namespace: DurableObjectNamespaceBinding = { + idFromName: (name) => name, + get: () => stub, + } + const store = createDurableObjectLockStore(namespace, { + leaseDurationMs: 100, + renewIntervalMs: 40, + createOwnerId: () => 'owner', + }) + const work = deferred() + const result = store.withLock('workspace', () => work.promise) + + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(41) + work.resolve('done') + + await expect(result).rejects.toThrow(/renew.*503/i) + expect(operations.at(-1)).toBe('/release') + }) + + it('aborts a lost lease before another owner enters after expiry', async () => { + vi.useFakeTimers() + vi.setSystemTime(4_000) + const storage = new FakeLockStorage() + const server = new CloudflareLockDurableObject({ storage }) + const stub: DurableObjectStubBinding = { + async fetch(input) { + const requestInput = + input instanceof Request ? input : new Request(input) + const operation = new URL(requestInput.url).pathname + const ownerId = await ownerIdFrom(requestInput) + if ( + ownerId === 'owner-1' && + (operation === '/renew' || operation === '/release') + ) { + return new Response(null, { status: 503 }) + } + return server.fetch(requestInput) + }, + } + const namespace: DurableObjectNamespaceBinding = { + idFromName: (name) => name, + get: () => stub, + } + const firstStore = createDurableObjectLockStore(namespace, { + leaseDurationMs: 100, + renewIntervalMs: 40, + retryDelayMs: 5, + acquireTimeoutMs: 200, + createOwnerId: () => 'owner-1', + }) + const secondStore = createDurableObjectLockStore(namespace, { + leaseDurationMs: 100, + renewIntervalMs: 40, + retryDelayMs: 5, + acquireTimeoutMs: 200, + createOwnerId: () => 'owner-2', + }) + const legacyStop = deferred() + let active = 0 + let maximumActive = 0 + let firstAborted = false + + const first = firstStore.withLock('workspace', async (signal) => { + active += 1 + maximumActive = Math.max(maximumActive, active) + if (signal instanceof AbortSignal) { + await waitForAbort(signal) + firstAborted = signal.aborted + } else { + await legacyStop.promise + } + active -= 1 + }) + const firstError = first.then( + () => undefined, + (error: unknown) => error, + ) + await vi.advanceTimersByTimeAsync(0) + const second = secondStore.withLock('workspace', async () => { + active += 1 + maximumActive = Math.max(maximumActive, active) + active -= 1 + return 'second' + }) + + await vi.advanceTimersByTimeAsync(110) + legacyStop.resolve() + await expect(second).resolves.toBe('second') + await expect(firstError).resolves.toBeInstanceOf(AggregateError) + + expect(firstAborted).toBe(true) + expect(maximumActive).toBe(1) + }) + + it('surfaces release failure', async () => { + const stub: DurableObjectStubBinding = { + fetch(input) { + const requestInput = + input instanceof Request ? input : new Request(input) + const operation = new URL(requestInput.url).pathname + return Promise.resolve( + new Response(null, { status: operation === '/release' ? 503 : 200 }), + ) + }, + } + const namespace: DurableObjectNamespaceBinding = { + idFromName: (name) => name, + get: () => stub, + } + const store = createDurableObjectLockStore(namespace, { + createOwnerId: () => 'owner', + }) + + await expect(store.withLock('workspace', async () => 42)).rejects.toThrow( + /release.*503/i, + ) + }) + + it('preserves a critical section rejection with an undefined reason', async () => { + const namespace: DurableObjectNamespaceBinding = { + idFromName: (name) => name, + get: () => ({ fetch: () => Promise.resolve(new Response()) }), + } + const store = createDurableObjectLockStore(namespace) + + await expect( + store.withLock('workspace', () => Promise.reject(undefined)), + ).rejects.toBeUndefined() + }) + + it('aggregates critical section and release failures', async () => { + const workError = new Error('work failed') + const stub: DurableObjectStubBinding = { + fetch(input) { + const requestInput = + input instanceof Request ? input : new Request(input) + const operation = new URL(requestInput.url).pathname + return Promise.resolve( + new Response(null, { status: operation === '/release' ? 503 : 200 }), + ) + }, + } + const namespace: DurableObjectNamespaceBinding = { + idFromName: (name) => name, + get: () => stub, + } + const store = createDurableObjectLockStore(namespace) + + const error = await store + .withLock('workspace', () => Promise.reject(workError)) + .then( + () => undefined, + (reason: unknown) => reason, + ) + + expect(error).toBeInstanceOf(AggregateError) + expect(error).toMatchObject({ + errors: [ + workError, + expect.objectContaining({ + message: expect.stringMatching(/release.*503/i), + }), + ], + }) + }) + + it('rejects invalid lease timing before acquiring', () => { + const namespace: DurableObjectNamespaceBinding = { + idFromName: (name) => name, + get: () => ({ fetch: () => Promise.resolve(new Response()) }), + } + expect(() => + createDurableObjectLockStore(namespace, { + leaseDurationMs: 100, + renewIntervalMs: 100, + }), + ).toThrow(/renewIntervalMs.*leaseDurationMs/) + }) +}) diff --git a/packages/ai-persistence-cloudflare/tests/package-contract.test.ts b/packages/ai-persistence-cloudflare/tests/package-contract.test.ts new file mode 100644 index 000000000..96eaff2f4 --- /dev/null +++ b/packages/ai-persistence-cloudflare/tests/package-contract.test.ts @@ -0,0 +1,52 @@ +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import packageJson from '../package.json' + +describe('Cloudflare package contract', () => { + it('publishes the root entry only — no migration CLI or shipped SQL', () => { + expect(packageJson.exports).toEqual({ + '.': { + types: './dist/esm/index.d.ts', + import: './dist/esm/index.js', + }, + }) + expect(packageJson).not.toHaveProperty('bin') + expect(packageJson.files).toEqual(['dist', 'src']) + expect(packageJson.description.toLowerCase()).toMatch(/drizzle|d1/) + expect(packageJson.description.toLowerCase()).toMatch(/lock/) + }) + + it('does not ship D1 migration SQL or a migrations CLI', async () => { + const root = fileURLToPath(new URL('..', import.meta.url)) + await expect( + readFile(`${root}/migrations/0000_tanstack_ai_initial.sql`, 'utf8'), + ).rejects.toMatchObject({ code: 'ENOENT' }) + await expect( + readFile(`${root}/src/assets/0000_tanstack_ai_initial.sql`, 'utf8'), + ).rejects.toMatchObject({ code: 'ENOENT' }) + await expect( + readFile(`${root}/bin/tanstack-ai-cloudflare-migrations.mjs`, 'utf8'), + ).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('keeps Node built-ins, Buffer, and migration tooling out of the root graph', async () => { + const rootFiles = ['bindings.ts', 'd1.ts', 'index.ts', 'locks.ts'] + for (const filename of rootFiles) { + const contents = await readFile( + fileURLToPath(new URL(`../src/${filename}`, import.meta.url)), + 'utf8', + ) + expect(contents, filename).not.toMatch(/from ['"]node:/) + expect(contents, filename).not.toMatch(/\bBuffer\b/) + } + const root = await readFile( + fileURLToPath(new URL('../src/index.ts', import.meta.url)), + 'utf8', + ) + expect(root).not.toMatch(/d1Migrations/) + expect(root).not.toMatch(/from ['"]\.\/migrations['"]/) + expect(root).not.toMatch(/from ['"]\.\/migration-cli['"]/) + expect(root).not.toMatch(/from ['"]\.\/cli['"]/) + }) +}) diff --git a/packages/ai-persistence-cloudflare/tests/runtime.conformance.test.ts b/packages/ai-persistence-cloudflare/tests/runtime.conformance.test.ts new file mode 100644 index 000000000..74d99b8d7 --- /dev/null +++ b/packages/ai-persistence-cloudflare/tests/runtime.conformance.test.ts @@ -0,0 +1,83 @@ +/// +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { Miniflare } from 'miniflare' +import { + createDefaultSqliteSchema, + ensureSqliteTables, +} from '@tanstack/ai-persistence-drizzle' +import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' +import { cloudflarePersistence } from '../src/index' +import { composePersistence } from '@tanstack/ai-persistence' +import type { ChatPersistence, InterruptStore } from '@tanstack/ai-persistence' + +interface RuntimeBindings { + AI_DB: D1Database +} + +/** + * Bootstrap stock tables on a Miniflare D1 binding the same way production + * apps do: derive DDL from the Drizzle SQLite schema SoT (not package-owned + * migration SQL). + */ +async function ensureDefaultTables(d1: D1Database): Promise { + const schema = createDefaultSqliteSchema() + const statements: Array = [] + ensureSqliteTables((sql) => { + statements.push(sql) + }, schema) + await d1.batch(statements.map((statement) => d1.prepare(statement))) +} + +describe('Cloudflare persistence on Miniflare bindings', () => { + let miniflare: Miniflare + let persistence: ChatPersistence + + beforeAll(async () => { + miniflare = new Miniflare({ + compatibilityDate: '2026-06-24', + d1Databases: ['AI_DB'], + modules: true, + script: 'export default { fetch() { return new Response("ok") } }', + }) + const bindings = await miniflare.getBindings() + await ensureDefaultTables(bindings.AI_DB) + persistence = cloudflarePersistence({ + d1: bindings.AI_DB, + }) + }) + + afterAll(async () => { + await miniflare.dispose() + }) + + runPersistenceConformance('cloudflare-d1', () => persistence) + + it('composes a custom interrupt store while retaining D1 runs', () => { + const customInterrupts: InterruptStore = { + create: () => Promise.resolve(), + resolve: () => Promise.resolve(), + cancel: () => Promise.resolve(), + get: () => Promise.resolve(null), + list: () => Promise.resolve([]), + listPending: () => Promise.resolve([]), + listByRun: () => Promise.resolve([]), + listPendingByRun: () => Promise.resolve([]), + } + const composed = composePersistence(persistence, { + overrides: { interrupts: customInterrupts }, + }) + + expect(composed.stores.interrupts).toBe(customInterrupts) + expect(composed.stores.runs).toBe(persistence.stores.runs) + }) + + it('removes only stores explicitly disabled by an override', () => { + const composed = composePersistence(persistence, { + overrides: { interrupts: false }, + }) + + expect('interrupts' in composed.stores).toBe(false) + expect(composed.stores.runs).toBe(persistence.stores.runs) + expect(composed.stores.messages).toBe(persistence.stores.messages) + }) +}) diff --git a/packages/ai-persistence-cloudflare/tsconfig.json b/packages/ai-persistence-cloudflare/tsconfig.json new file mode 100644 index 000000000..1ca53bbf0 --- /dev/null +++ b/packages/ai-persistence-cloudflare/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "types": ["node", "@cloudflare/workers-types"] + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ai-persistence-cloudflare/vite.config.ts b/packages/ai-persistence-cloudflare/vite.config.ts new file mode 100644 index 000000000..cb97d201f --- /dev/null +++ b/packages/ai-persistence-cloudflare/vite.config.ts @@ -0,0 +1,40 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + '**/types.ts', + ], + include: ['src/**/*.ts'], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts'], + srcDir: './src', + cjs: false, + beforeWriteDeclarationFile(filePath, content) { + if (!/[\\/]index\.d\.ts$/.test(filePath)) return content + return `/// \n${content}` + }, + }), +) diff --git a/packages/ai-persistence-drizzle/bin/tanstack-ai-drizzle-schema.mjs b/packages/ai-persistence-drizzle/bin/tanstack-ai-drizzle-schema.mjs new file mode 100755 index 000000000..c33270c7f --- /dev/null +++ b/packages/ai-persistence-drizzle/bin/tanstack-ai-drizzle-schema.mjs @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import '../dist/esm/schema-cli-main.js' diff --git a/packages/ai-persistence-drizzle/package.json b/packages/ai-persistence-drizzle/package.json new file mode 100644 index 000000000..06f4ff242 --- /dev/null +++ b/packages/ai-persistence-drizzle/package.json @@ -0,0 +1,74 @@ +{ + "name": "@tanstack/ai-persistence-drizzle", + "version": "0.0.0", + "description": "Schema-first Drizzle persistence for TanStack AI (SQLite and Postgres). Emit a schema, own migrations with drizzle-kit, or use the Node SQLite convenience factory with stock defaults.", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-persistence-drizzle" + }, + "keywords": [ + "ai", + "tanstack", + "persistence", + "drizzle", + "sqlite", + "postgres" + ], + "type": "module", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + }, + "./sqlite": { + "types": "./dist/esm/sqlite.d.ts", + "import": "./dist/esm/sqlite.js" + }, + "./sqlite-schema": { + "types": "./dist/esm/sqlite/default-schema.d.ts", + "import": "./dist/esm/sqlite/default-schema.js" + }, + "./pg-schema": { + "types": "./dist/esm/pg/default-schema.d.ts", + "import": "./dist/esm/pg/default-schema.js" + } + }, + "bin": { + "tanstack-ai-drizzle-schema": "./bin/tanstack-ai-drizzle-schema.mjs" + }, + "files": [ + "bin", + "dist", + "src", + "scripts" + ], + "scripts": { + "codegen:pg": "node ./scripts/codegen-pg-from-sqlite.ts", + "codegen:pg:check": "node ./scripts/codegen-pg-from-sqlite.ts --check", + "build": "pnpm codegen:pg && 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:*", + "@tanstack/ai-persistence": "workspace:*", + "drizzle-orm": ">=0.44.0" + }, + "devDependencies": { + "@electric-sql/pglite": "^0.3.0", + "@tanstack/ai": "workspace:*", + "@tanstack/ai-persistence": "workspace:*", + "@vitest/coverage-v8": "4.0.14", + "drizzle-orm": "^0.45.0" + } +} diff --git a/packages/ai-persistence-drizzle/scripts/codegen-pg-from-sqlite.ts b/packages/ai-persistence-drizzle/scripts/codegen-pg-from-sqlite.ts new file mode 100644 index 000000000..e3afdb8de --- /dev/null +++ b/packages/ai-persistence-drizzle/scripts/codegen-pg-from-sqlite.ts @@ -0,0 +1,191 @@ +#!/usr/bin/env node +/** + * Generate Postgres schema modules from the SQLite sources of truth. + * + * Source of truth: + * src/sqlite/default-schema.ts + * src/sqlite/assets/tanstack-ai-schema.ts + * + * Generated (do not edit by hand): + * src/pg/default-schema.ts + * src/pg/assets/tanstack-ai-schema.ts + * + * Usage (Node 22+ type-stripping; Node 24 recommended): + * node scripts/codegen-pg-from-sqlite.ts # write + * node scripts/codegen-pg-from-sqlite.ts --check # exit 1 if stale + */ +import { readFile, writeFile, mkdir } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const packageRoot = join(fileURLToPath(import.meta.url), '..', '..') + +interface CodegenTarget { + source: string + dest: string +} + +const targets: ReadonlyArray = [ + { + source: 'src/sqlite/default-schema.ts', + dest: 'src/pg/default-schema.ts', + }, + { + source: 'src/sqlite/assets/tanstack-ai-schema.ts', + dest: 'src/pg/assets/tanstack-ai-schema.ts', + }, +] + +const GENERATED_BANNER = `/** + * GENERATED by scripts/codegen-pg-from-sqlite.ts — do not edit by hand. + * Source: %SOURCE% + * Regenerate: pnpm codegen:pg + */ + +` + +/** Matches both current (.ts) and legacy (.mjs) codegen banners. */ +const GENERATED_BANNER_RE = + /^\/\*\*\n \* GENERATED by scripts\/codegen-pg-from-sqlite\.(?:ts|mjs)[\s\S]*?\*\/\n\n/ + +/** + * Mechanical SQLite → Postgres schema projection. + * + * Mappings: + * drizzle-orm/sqlite-core → drizzle-orm/pg-core + * sqliteTable → pgTable + * text(name, { mode: 'json' }) → jsonb(name) + * integer(name) → bigint(name, { mode: 'number' }) + * TanstackAiSqliteSchema / createDefaultSqliteSchema → Pg equivalents + * provider: 'sqlite' → provider: 'pg' + */ +export function transformSqliteSchemaToPg( + source: string, + { sourcePath }: { sourcePath: string }, +): string { + let out = source + + // Drop a previous generated banner if present. + out = out.replace(GENERATED_BANNER_RE, '') + + // Strip SQLite-only "source of truth" notes from the copied docs. + out = out + .split('\n') + .filter( + (line) => + !line.includes('**Source of truth for the Postgres') && + !line.includes('`pnpm codegen:pg` to regenerate'), + ) + .join('\n') + + // JSON text columns → jsonb (before other text/integer rewrites). + out = out.replace( + /text\((['"][^'"]+['"])\s*,\s*\{\s*mode:\s*['"]json['"]\s*\}\)/g, + 'jsonb($1)', + ) + + // Epoch-ms integers → bigint with number mode. + out = out.replace( + /integer\((['"][^'"]+['"])\)/g, + "bigint($1, { mode: 'number' })", + ) + + out = out.replace(/\bsqliteTable\b/g, 'pgTable') + out = out.replace(/TanstackAiSqliteSchema/g, 'TanstackAiPgSchema') + out = out.replace(/createDefaultSqliteSchema/g, 'createDefaultPgSchema') + out = out.replace(/provider: 'sqlite'/g, "provider: 'pg'") + + // Import block: replace sqlite-core import with pg-core imports. + out = out.replace( + /import\s*\{[^}]+\}\s*from\s*['"]drizzle-orm\/sqlite-core['"]/s, + `import { + bigint, + index, + jsonb, + pgTable, + primaryKey, + text, +} from 'drizzle-orm/pg-core'`, + ) + + // Doc / prose adjustments. + out = out.replace( + /`tanstack-ai-drizzle-schema`(?! --dialect)/g, + '`tanstack-ai-drizzle-schema --dialect pg`', + ) + out = out.replace( + /Default SQLite table definitions/g, + 'Default Postgres table definitions', + ) + out = out.replace( + /used by \{@link sqlitePersistence\} and by apps/g, + 'for apps', + ) + out = out.replace( + /Pass the result to \{@link drizzlePersistence\} or \{@link sqlitePersistence\},/g, + "Pass the result to {@link drizzlePersistence} with `provider: 'pg'`,", + ) + out = out.replace( + /for `drizzlePersistence\(db, \{ schema \}\)` and drizzle-kit\./g, + "for `drizzlePersistence(db, { provider: 'pg', schema })` and drizzle-kit.", + ) + + // Collapse accidental blank-line runs from filtered lines (keep max 2). + out = out.replace(/\n{3,}/g, '\n\n') + // Drop empty JSDoc lines left after stripping "source of truth" notes. + out = out.replace(/\n \*\n \*\//g, '\n */') + + const banner = GENERATED_BANNER.replace('%SOURCE%', sourcePath) + return `${banner}${out.trimStart()}` +} + +function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error +} + +async function processTarget( + target: CodegenTarget, + { check }: { check: boolean }, +): Promise { + const sourcePath = join(packageRoot, target.source) + const destPath = join(packageRoot, target.dest) + const source = await readFile(sourcePath, 'utf8') + const generated = transformSqliteSchemaToPg(source, { + sourcePath: target.source, + }) + + if (check) { + let existing: string + try { + existing = await readFile(destPath, 'utf8') + } catch (error) { + if (isErrnoException(error) && error.code === 'ENOENT') { + console.error(`Missing generated file: ${target.dest}`) + process.exitCode = 1 + return + } + throw error + } + if (existing !== generated) { + console.error( + `Stale generated file: ${target.dest}\nRun: pnpm codegen:pg`, + ) + process.exitCode = 1 + return + } + console.log(`ok ${target.dest}`) + return + } + + await mkdir(dirname(destPath), { recursive: true }) + await writeFile(destPath, generated, 'utf8') + console.log(`wrote ${target.dest}`) +} + +const check = process.argv.includes('--check') +for (const target of targets) { + await processTarget(target, { check }) +} +if (check && process.exitCode) { + process.exit(process.exitCode) +} diff --git a/packages/ai-persistence-drizzle/src/assets.d.ts b/packages/ai-persistence-drizzle/src/assets.d.ts new file mode 100644 index 000000000..780e40110 --- /dev/null +++ b/packages/ai-persistence-drizzle/src/assets.d.ts @@ -0,0 +1,4 @@ +declare module '*.ts?raw' { + const contents: string + export default contents +} diff --git a/packages/ai-persistence-drizzle/src/core/persistence.ts b/packages/ai-persistence-drizzle/src/core/persistence.ts new file mode 100644 index 000000000..4f8725b49 --- /dev/null +++ b/packages/ai-persistence-drizzle/src/core/persistence.ts @@ -0,0 +1,134 @@ +/** + * Wire TanStack AI persistence stores over a migrated Drizzle database. + * + * Shared entry used by both dialect folders. Schema-first: this package does + * **not** ship SQL migrations — pass a project-owned schema (or a default + * factory from `sqlite/` / `pg/`). + */ +import { is } from 'drizzle-orm' +import { PgDatabase } from 'drizzle-orm/pg-core' +import { BaseSQLiteDatabase } from 'drizzle-orm/sqlite-core' +import { assertTanstackAiSchema } from './schema-contract' +import { + createInterruptStore, + createMessageStore, + createMetadataStore, + createRunStore, +} from './stores' +import type { PgQueryResultHKT } from 'drizzle-orm/pg-core' +import type { ChatPersistence } from '@tanstack/ai-persistence' +import type { + TanstackAiPgSchema, + TanstackAiSqliteSchema, +} from './schema-contract' +import type { DrizzleSqliteDb, TanstackAiTables } from './stores' + +/** + * Any Drizzle Postgres database (node-postgres, postgres.js, neon, pglite, …). + * + * Typed as the schema-agnostic slice of the query builder the stores actually + * use, so a BYO `db` constructed with any `{ schema }` is assignable + * regardless of its `TFullSchema`. + */ +export type DrizzlePgDb = Pick< + PgDatabase, + 'select' | 'insert' | 'update' | 'delete' +> + +export interface SqlitePersistenceConfig { + /** The database dialect of `db` and `schema`. */ + provider: 'sqlite' + /** + * Your TanStack AI schema tables. Emit a starter with + * `tanstack-ai-drizzle-schema`, add it to drizzle-kit, generate migrations + * in your project, and pass the module here. Table/column database names are + * yours (including drizzle `casing`); extra app-owned columns are fine when + * nullable or defaulted. + * + * For the stock tables without a project file, use + * {@link createDefaultSqliteSchema}. + */ + schema: TanstackAiSqliteSchema +} + +export interface PgPersistenceConfig { + /** The database dialect of `db` and `schema`. */ + provider: 'pg' + /** + * Your TanStack AI schema tables. Emit a starter with + * `tanstack-ai-drizzle-schema --dialect pg`, add it to drizzle-kit, generate + * migrations in your project, and pass the module here. Table/column + * database names are yours (including drizzle `casing`); extra app-owned + * columns are fine when nullable or defaulted. + * + * For the stock tables without a project file, use + * {@link createDefaultPgSchema}. + */ + schema: TanstackAiPgSchema +} + +export type DrizzlePersistenceOptions = + | SqlitePersistenceConfig + | PgPersistenceConfig + +/** Packaged Drizzle backend: full {@link ChatPersistence} (messages + runs + …). */ +export type DrizzlePersistence = ChatPersistence + +/** + * Wire TanStack AI persistence stores over a migrated Drizzle database. + * + * Returns {@link ChatPersistence}. `provider` declares the dialect; `db` and + * `schema` must match it (checked at compile time by the overloads and at + * runtime by the schema assertion). `schema` is required — this package never + * applies bundled DDL. Own migrations via drizzle-kit (after emitting the + * schema), or bootstrap a known schema locally with {@link ensureSqliteTables} + * / {@link ensurePgTables}. + * + * State stores only — locks are a separate concern. For multi-instance + * coordination use `withLocks` with a distributed `LockStore` (for example + * `createDurableObjectLockStore` from `@tanstack/ai-persistence-cloudflare`). + */ +export function drizzlePersistence( + db: DrizzleSqliteDb, + options: SqlitePersistenceConfig, +): ChatPersistence +export function drizzlePersistence( + db: DrizzlePgDb, + options: PgPersistenceConfig, +): ChatPersistence +export function drizzlePersistence( + db: DrizzleSqliteDb | DrizzlePgDb, + options: DrizzlePersistenceOptions, +): ChatPersistence { + assertTanstackAiSchema(options.schema, options.provider) + // The overloads guarantee db/provider/schema agree for typed callers; + // re-verify the db dialect at runtime for anyone calling through `any`. + const dialectOk = + options.provider === 'pg' ? is(db, PgDatabase) : is(db, BaseSQLiteDatabase) + if (!dialectOk) { + throw new TypeError( + `drizzlePersistence: provider is '${options.provider}' but \`db\` is not a Drizzle ${ + options.provider === 'pg' ? 'Postgres' : 'SQLite' + } database.`, + ) + } + // THE SEAM. The stores are implemented once against the SQLite builder + // types, but the Postgres and SQLite query builders share the exact method + // surface the stores use (select/insert/update/delete, onConflictDo*, + // eq/and/asc), and the generated SQL dialect comes from the runtime + // db/table objects — which the checks above have just proven match + // `provider`. TypeScript cannot correlate the db/schema unions per provider + // (no correlated union types), so the pg pair is re-faced onto the SQLite + // signatures here — the single place dialect typing is erased. The pg + // conformance suite runs the result against a real Postgres engine. + const tables = options.schema as TanstackAiTables + const storeDb = db as DrizzleSqliteDb + return { + stores: { + messages: createMessageStore(storeDb, tables), + runs: createRunStore(storeDb, tables), + interrupts: createInterruptStore(storeDb, tables), + metadata: createMetadataStore(storeDb, tables), + }, + } +} diff --git a/packages/ai-persistence-drizzle/src/core/schema-cli.ts b/packages/ai-persistence-drizzle/src/core/schema-cli.ts new file mode 100644 index 000000000..6ac0503a8 --- /dev/null +++ b/packages/ai-persistence-drizzle/src/core/schema-cli.ts @@ -0,0 +1,147 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { drizzleSchemaFilename, drizzleSchemaSources } from './schema-source' +import type { DrizzleSchemaDialect } from './schema-source' + +export interface SchemaCliOutput { + writeStdout: (value: string) => void +} + +export class SchemaCliError extends Error { + constructor(message: string) { + super(message) + this.name = 'SchemaCliError' + } +} + +const usage = `Usage: tanstack-ai-drizzle-schema (--out | --stdout) [--dialect ] [--force] + +Emits the TanStack AI Drizzle schema module so **your** project owns it. + +This package does not ship SQL migrations. After emitting: + + 1. Add the file to your drizzle-kit schema paths + 2. Run drizzle-kit generate / migrate in your app + 3. Pass the schema to drizzlePersistence(db, { provider, schema }) + +Options: + --out Write ${drizzleSchemaFilename} into the directory. + --stdout Print the schema module. + --dialect Schema dialect: sqlite (default) or pg. + --force Replace a divergent file when copying. + --help Show this help. +` + +interface ParsedArguments { + dialect: DrizzleSchemaDialect + force: boolean + help: boolean + outDirectory?: string + stdout: boolean +} + +function parseDialect(value: string | undefined): DrizzleSchemaDialect { + if (value === 'sqlite' || value === 'pg') return value + throw new SchemaCliError( + `--dialect must be "sqlite" or "pg"; received: ${value ?? ''}`, + ) +} + +function parseArguments(args: ReadonlyArray): ParsedArguments { + let dialect: DrizzleSchemaDialect | undefined + let force = false + let help = false + let outDirectory: string | undefined + let stdout = false + + for (let index = 0; index < args.length; index++) { + const argument = args[index] + if (argument === '--force') { + force = true + } else if (argument === '--help' || argument === '-h') { + help = true + } else if (argument === '--stdout') { + stdout = true + } else if (argument === '--dialect') { + if (dialect !== undefined) { + throw new SchemaCliError('--dialect may only be provided once.') + } + dialect = parseDialect(args[index + 1]) + index++ + } else if (argument === '--out') { + const value = args[index + 1] + if (!value || value.startsWith('-')) { + throw new SchemaCliError('--out requires a directory.') + } + if (outDirectory !== undefined) { + throw new SchemaCliError('--out may only be provided once.') + } + outDirectory = value + index++ + } else { + throw new SchemaCliError(`Unknown argument: ${argument ?? ''}`) + } + } + + return { dialect: dialect ?? 'sqlite', force, help, outDirectory, stdout } +} + +function isMissingFileError(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +async function readExistingFile(path: string): Promise { + try { + return await readFile(path, 'utf8') + } catch (error) { + if (isMissingFileError(error)) return undefined + throw error + } +} + +/** Execute the schema emit CLI. Exported for deterministic CLI tests. */ +export async function runDrizzleSchemaCli( + args: ReadonlyArray, + output: SchemaCliOutput = { + writeStdout(value) { + process.stdout.write(value) + }, + }, +): Promise { + const parsed = parseArguments(args) + if (parsed.help) { + output.writeStdout(usage) + return + } + + const outputCount = + Number(parsed.stdout) + Number(parsed.outDirectory != null) + if (outputCount !== 1) { + throw new SchemaCliError( + 'Provide exactly one output mode: --out or --stdout.', + ) + } + const source = drizzleSchemaSources[parsed.dialect] + if (parsed.stdout) { + if (parsed.force) { + throw new SchemaCliError('--force can only be used with --out.') + } + output.writeStdout(`${source.trimEnd()}\n`) + return + } + + const outDirectory = parsed.outDirectory + if (!outDirectory) { + throw new SchemaCliError('--out requires a directory.') + } + await mkdir(outDirectory, { recursive: true }) + const destination = join(outDirectory, drizzleSchemaFilename) + const existing = await readExistingFile(destination) + if (existing === source) return + if (existing !== undefined && !parsed.force) { + throw new SchemaCliError( + `Refusing to overwrite divergent schema file: ${destination}. Re-run with --force to replace it.`, + ) + } + await writeFile(destination, source, 'utf8') +} diff --git a/packages/ai-persistence-drizzle/src/core/schema-contract.ts b/packages/ai-persistence-drizzle/src/core/schema-contract.ts new file mode 100644 index 000000000..9de6afb3a --- /dev/null +++ b/packages/ai-persistence-drizzle/src/core/schema-contract.ts @@ -0,0 +1,186 @@ +/** + * Structural contract for a user-supplied TanStack AI Drizzle schema. + * + * The contract itself is dialect-neutral: {@link TanstackAiTableShapes} lists + * the logical columns and their decoded data shapes, nothing else. Table and + * column **database names are free**, and any Drizzle dialect can project the + * shapes into concrete tables — {@link TanstackAiSqliteSchema} / + * {@link TanstackAiPgSchema} are the dialect projections the stores consume. + * + * `drizzlePersistence` accepts any schema whose tables and columns carry the + * required data shapes. Emit a starter with `tanstack-ai-drizzle-schema`, + * generate DDL through your own drizzle-kit journal — including projects using + * drizzle's `casing` name transforms — and extend tables with extra columns + * (for example an ownership `user_id`) without falling out of contract. + * + * Only the columns listed here are read or written by the stores. Extra + * columns must therefore be nullable or defaulted so inserts succeed. + */ +import { Column, is } from 'drizzle-orm' +import { PgTable } from 'drizzle-orm/pg-core' +import { SQLiteTable } from 'drizzle-orm/sqlite-core' +import type { AnyPgColumn } from 'drizzle-orm/pg-core' +import type { AnySQLiteColumn } from 'drizzle-orm/sqlite-core' +import type { InterruptRecord, RunStatus } from '@tanstack/ai-persistence' +import type { ModelMessage, TokenUsage } from '@tanstack/ai' + +/** Database dialects the Drizzle backend supports. */ +export type DrizzleProvider = 'sqlite' | 'pg' + +/** + * Decoded (`data`) column shapes per store table — the dialect-neutral single + * source of truth for the schema contract. Nullability, database names, and + * driver specifics are intentionally unconstrained; only the base data shape + * of each column is fixed. + */ +export interface TanstackAiTableShapes { + /** Thread message history (`MessageStore`). */ + messages: { + threadId: string + messagesJson: Array + } + /** Run lifecycle records (`RunStore`). */ + runs: { + runId: string + threadId: string + status: RunStatus + startedAt: number + finishedAt: number + error: string + usageJson: TokenUsage + } + /** Interrupt / approval records (`InterruptStore`). */ + interrupts: { + interruptId: string + runId: string + threadId: string + status: InterruptRecord['status'] + requestedAt: number + resolvedAt: number + payloadJson: Record + responseJson: unknown + } + /** Scoped key/value metadata (`MetadataStore`). */ + metadata: { + scope: string + key: string + valueJson: unknown + } +} + +/** + * The SQLite projection of {@link TanstackAiTableShapes}: what + * `drizzlePersistence` can operate over. Satisfied by + * {@link createDefaultSqliteSchema} and by the file emitted by + * `tanstack-ai-drizzle-schema`. Other dialects can project the same shapes + * over their own table/column types when their stores land. + */ +export type TanstackAiSqliteSchema = { + [TableKey in keyof TanstackAiTableShapes]: SQLiteTable & { + [ColumnKey in keyof TanstackAiTableShapes[TableKey]]: AnySQLiteColumn<{ + data: TanstackAiTableShapes[TableKey][ColumnKey] + }> + } +} + +/** + * The Postgres projection of {@link TanstackAiTableShapes}: what + * `drizzlePersistence` can operate over with `provider: 'pg'`. Satisfied by + * {@link createDefaultPgSchema} and by the file emitted by + * `tanstack-ai-drizzle-schema --dialect pg`. + */ +export type TanstackAiPgSchema = { + [TableKey in keyof TanstackAiTableShapes]: PgTable & { + [ColumnKey in keyof TanstackAiTableShapes[TableKey]]: AnyPgColumn<{ + data: TanstackAiTableShapes[TableKey][ColumnKey] + }> + } +} + +/** Any dialect projection accepted by the runtime schema assertion. */ +export type TanstackAiAnySchema = TanstackAiSqliteSchema | TanstackAiPgSchema + +/** @deprecated Use {@link TanstackAiSqliteSchema}. */ +export type TanstackAiSchema = TanstackAiSqliteSchema + +const requiredColumns: { + [TableKey in keyof TanstackAiTableShapes]: ReadonlyArray< + keyof TanstackAiTableShapes[TableKey] & string + > +} = { + messages: ['threadId', 'messagesJson'], + runs: [ + 'runId', + 'threadId', + 'status', + 'startedAt', + 'finishedAt', + 'error', + 'usageJson', + ], + interrupts: [ + 'interruptId', + 'runId', + 'threadId', + 'status', + 'requestedAt', + 'resolvedAt', + 'payloadJson', + 'responseJson', + ], + metadata: ['scope', 'key', 'valueJson'], +} + +const tableKeys = Object.keys(requiredColumns) as Array< + keyof TanstackAiTableShapes +> + +/** A user-supplied schema failed the TanStack AI Drizzle schema contract. */ +export class DrizzleSchemaError extends Error { + constructor(problems: ReadonlyArray) { + super( + `Invalid TanStack AI Drizzle schema:\n${problems + .map((problem) => ` - ${problem}`) + .join('\n')}`, + ) + this.name = 'DrizzleSchemaError' + } +} + +const providerTables = { + sqlite: { table: SQLiteTable, label: 'SQLite' }, + pg: { table: PgTable, label: 'Postgres' }, +} as const + +/** + * Assert `input` structurally satisfies the store contract at runtime: every + * table is a Drizzle table of the declared `provider`'s dialect and carries + * every column property the stores reference. Column data shapes are enforced + * by the dialect projection types ({@link TanstackAiSqliteSchema}, + * {@link TanstackAiPgSchema}) at compile time and are not re-checked here. + */ +export function assertTanstackAiSchema( + input: TanstackAiAnySchema, + provider: DrizzleProvider, +): void { + const dialect = providerTables[provider] + const problems: Array = [] + for (const tableKey of tableKeys) { + const table: unknown = input[tableKey] + if (!is(table, dialect.table)) { + problems.push( + `\`${tableKey}\` is not a Drizzle ${dialect.label} table (provider '${provider}').`, + ) + continue + } + for (const columnKey of requiredColumns[tableKey]) { + const column: unknown = Reflect.get(table, columnKey) + if (!is(column, Column)) { + problems.push( + `\`${tableKey}.${columnKey}\` is missing or not a Drizzle column.`, + ) + } + } + } + if (problems.length > 0) throw new DrizzleSchemaError(problems) +} diff --git a/packages/ai-persistence-drizzle/src/core/schema-source.ts b/packages/ai-persistence-drizzle/src/core/schema-source.ts new file mode 100644 index 000000000..3f565dc43 --- /dev/null +++ b/packages/ai-persistence-drizzle/src/core/schema-source.ts @@ -0,0 +1,37 @@ +import sqliteSource from '../sqlite/assets/tanstack-ai-schema.ts?raw' +import pgSource from '../pg/assets/tanstack-ai-schema.ts?raw' + +/** Filename used by the schema emit CLI (both dialects). */ +export const drizzleSchemaFilename = 'tanstack-ai-schema.ts' + +/** Dialects the schema emit CLI can target. */ +export type DrizzleSchemaDialect = 'sqlite' | 'pg' + +/** + * Strip the package-internal codegen banner so CLI emit produces a clean + * user-owned module (not "do not edit by hand"). + */ +function stripCodegenBanner(source: string): string { + return source.replace( + /^\/\*\*\n \* GENERATED by scripts\/codegen-pg-from-sqlite\.(?:ts|mjs)[\s\S]*?\*\/\n\n/, + '', + ) +} + +/** + * Source text of the user-facing TanStack AI Drizzle schema module per + * dialect, emitted by `tanstack-ai-drizzle-schema`. Each is structurally + * identical to its default-schema factory (a test enforces it). The file is + * written into the project so **their** drizzle-kit journal owns the DDL — + * this package never ships or applies SQL migrations. + * + * The Postgres asset is generated from the SQLite asset + * (`pnpm codegen:pg`); do not edit `pg/assets/` by hand. + */ +export const drizzleSchemaSources: Record = { + sqlite: stripCodegenBanner(sqliteSource), + pg: stripCodegenBanner(pgSource), +} + +/** @deprecated Use {@link drizzleSchemaSources}.sqlite. */ +export const drizzleSchemaSource: string = drizzleSchemaSources.sqlite diff --git a/packages/ai-persistence-drizzle/src/core/stores.ts b/packages/ai-persistence-drizzle/src/core/stores.ts new file mode 100644 index 000000000..23b8652e1 --- /dev/null +++ b/packages/ai-persistence-drizzle/src/core/stores.ts @@ -0,0 +1,309 @@ +/** + * AIPersistence store implementations over a Drizzle database. + * + * Shared, dialect-agnostic query surface: written once against the SQLite + * builder types; the Postgres path re-faces its runtime objects onto these + * signatures in {@link drizzlePersistence} (see `core/persistence.ts`). Stores + * operate on injected table objects from the caller's schema. JSON columns are + * expected to use Drizzle's json-decoding column types (SQLite + * `text({ mode: 'json' })`, Postgres `jsonb`) so values decode to the contract + * data shapes. + */ +import { and, asc, desc, eq } from 'drizzle-orm' +import type { BaseSQLiteDatabase } from 'drizzle-orm/sqlite-core' +import type { ModelMessage, TokenUsage } from '@tanstack/ai' +import type { + InterruptRecord, + InterruptStore, + MessageStore, + MetadataStore, + RunRecord, + RunStatus, + RunStore, +} from '@tanstack/ai-persistence' +import type { TanstackAiSqliteSchema } from './schema-contract' + +/** + * Any Drizzle sqlite database (better-sqlite3, libsql, node:sqlite proxy, D1, …). + * + * Typed as the schema-agnostic slice of the query builder we actually use, so a + * BYO `db` constructed with any `{ schema }` is assignable regardless of its + * `TFullSchema` (which is invariant on the full `BaseSQLiteDatabase`). + * + * Postgres databases are re-faced onto this surface at the + * {@link drizzlePersistence} boundary — the method set used here is shared. + */ +export type DrizzleSqliteDb = Pick< + BaseSQLiteDatabase<'sync' | 'async', unknown>, + 'select' | 'insert' | 'update' | 'delete' +> + +/** @deprecated Use {@link DrizzleSqliteDb}. */ +export type DrizzleDb = DrizzleSqliteDb + +/** + * Table set the stores read/write. Only column *data* shapes matter; table and + * column database names are carried by the runtime objects. + */ +export type TanstackAiTables = TanstackAiSqliteSchema + +type RunRow = { + runId: string + threadId: string + status: RunStatus + startedAt: number + finishedAt: number | null + error: string | null + usageJson: TokenUsage | null +} + +type InterruptRow = { + interruptId: string + runId: string + threadId: string + status: InterruptRecord['status'] + requestedAt: number + resolvedAt: number | null + payloadJson: Record + responseJson: unknown | null +} + +export function createMessageStore( + db: DrizzleSqliteDb, + { messages }: TanstackAiTables, +): MessageStore { + return { + async loadThread(threadId) { + const rows = await db + .select({ messagesJson: messages.messagesJson }) + .from(messages) + .where(eq(messages.threadId, threadId)) + return rows[0]?.messagesJson ?? [] + }, + async saveThread(threadId, msgs: Array) { + await db + .insert(messages) + .values({ threadId, messagesJson: msgs }) + .onConflictDoUpdate({ + target: messages.threadId, + set: { messagesJson: msgs }, + }) + }, + } +} + +function mapRun(row: RunRow): RunRecord { + return { + runId: row.runId, + threadId: row.threadId, + status: row.status, + startedAt: row.startedAt, + ...(row.finishedAt != null ? { finishedAt: row.finishedAt } : {}), + ...(row.error != null ? { error: row.error } : {}), + ...(row.usageJson != null ? { usage: row.usageJson } : {}), + } +} + +export function createRunStore( + db: DrizzleSqliteDb, + { runs }: TanstackAiTables, +): RunStore { + const store: RunStore = { + async createOrResume(input) { + const existing = await store.get(input.runId) + if (existing) return existing + const record: RunRecord = { + runId: input.runId, + threadId: input.threadId, + status: input.status ?? 'running', + startedAt: input.startedAt, + } + await db + .insert(runs) + .values({ + runId: record.runId, + threadId: record.threadId, + status: record.status, + startedAt: record.startedAt, + }) + .onConflictDoNothing({ target: runs.runId }) + return (await store.get(input.runId)) ?? record + }, + async update(runId, patch) { + const set: { + status?: RunStatus + finishedAt?: number | null + error?: string | null + usageJson?: TokenUsage | null + } = {} + if (patch.status !== undefined) set.status = patch.status + if (patch.finishedAt !== undefined) set.finishedAt = patch.finishedAt + if (patch.error !== undefined) set.error = patch.error + if (patch.usage !== undefined) set.usageJson = patch.usage + if (Object.keys(set).length === 0) return + await db.update(runs).set(set).where(eq(runs.runId, runId)) + }, + async get(runId) { + const rows = await db.select().from(runs).where(eq(runs.runId, runId)) + const row = rows[0] as RunRow | undefined + return row ? mapRun(row) : null + }, + async findActiveRun(threadId) { + const rows = await db + .select() + .from(runs) + .where(and(eq(runs.threadId, threadId), eq(runs.status, 'running'))) + .orderBy(desc(runs.startedAt)) + .limit(1) + const row = rows[0] as RunRow | undefined + return row ? mapRun(row) : null + }, + } + return store +} + +function mapInterrupt(row: InterruptRow): InterruptRecord { + return { + interruptId: row.interruptId, + runId: row.runId, + threadId: row.threadId, + status: row.status, + requestedAt: row.requestedAt, + ...(row.resolvedAt != null ? { resolvedAt: row.resolvedAt } : {}), + payload: row.payloadJson, + ...(row.responseJson != null ? { response: row.responseJson } : {}), + } +} + +export function createInterruptStore( + db: DrizzleSqliteDb, + { interrupts }: TanstackAiTables, +): InterruptStore { + return { + async create(record) { + await db + .insert(interrupts) + .values({ + interruptId: record.interruptId, + runId: record.runId, + threadId: record.threadId, + status: 'pending', + requestedAt: record.requestedAt, + payloadJson: record.payload, + responseJson: record.response ?? null, + }) + .onConflictDoNothing({ target: interrupts.interruptId }) + }, + async resolve(interruptId, response) { + await db + .update(interrupts) + .set({ + status: 'resolved', + resolvedAt: Date.now(), + responseJson: response ?? null, + }) + .where(eq(interrupts.interruptId, interruptId)) + }, + async cancel(interruptId) { + await db + .update(interrupts) + .set({ status: 'cancelled', resolvedAt: Date.now() }) + .where(eq(interrupts.interruptId, interruptId)) + }, + async get(interruptId) { + const rows = await db + .select() + .from(interrupts) + .where(eq(interrupts.interruptId, interruptId)) + const row = rows[0] as InterruptRow | undefined + return row ? mapInterrupt(row) : null + }, + async list(threadId) { + const rows = await db + .select() + .from(interrupts) + .where(eq(interrupts.threadId, threadId)) + .orderBy(asc(interrupts.requestedAt)) + return (rows as Array).map(mapInterrupt) + }, + async listPending(threadId) { + const rows = await db + .select() + .from(interrupts) + .where( + and( + eq(interrupts.threadId, threadId), + eq(interrupts.status, 'pending'), + ), + ) + .orderBy(asc(interrupts.requestedAt)) + return (rows as Array).map(mapInterrupt) + }, + async listByRun(runId) { + const rows = await db + .select() + .from(interrupts) + .where(eq(interrupts.runId, runId)) + .orderBy(asc(interrupts.requestedAt)) + return (rows as Array).map(mapInterrupt) + }, + async listPendingByRun(runId) { + const rows = await db + .select() + .from(interrupts) + .where( + and(eq(interrupts.runId, runId), eq(interrupts.status, 'pending')), + ) + .orderBy(asc(interrupts.requestedAt)) + return (rows as Array).map(mapInterrupt) + }, + } +} + +function assertStorableMetadata(value: unknown): void { + if (value == null) { + throw new TypeError( + `TanStack AI metadata values must be defined, non-null JSON; received ${ + value === undefined ? '`undefined`' : '`null`' + }. Use \`delete(scope, key)\` to clear a value.`, + ) + } +} + +export function createMetadataStore( + db: DrizzleSqliteDb, + { metadata }: TanstackAiTables, +): MetadataStore { + return { + async get(scope, key) { + const rows = await db + .select({ valueJson: metadata.valueJson }) + .from(metadata) + .where(and(eq(metadata.scope, scope), eq(metadata.key, key))) + const row = rows[0] + return row ? row.valueJson : null + }, + async set(scope, key, value) { + // SQL backends store JSON in a NOT NULL column and cannot persist a + // nullish value: the json column types bind a JS `null` as SQL NULL + // (they never serialize it to the text `"null"`), and `undefined` has no + // JSON at all — both violate NOT NULL. Reject nullish with a clear error + // (consistent with the sibling Prisma backend) instead of a cryptic + // driver failure. Unlike the in-memory reference, which round-trips + // nullish; use `delete` to clear. + assertStorableMetadata(value) + await db + .insert(metadata) + .values({ scope, key, valueJson: value }) + .onConflictDoUpdate({ + target: [metadata.scope, metadata.key], + set: { valueJson: value }, + }) + }, + async delete(scope, key) { + await db + .delete(metadata) + .where(and(eq(metadata.scope, scope), eq(metadata.key, key))) + }, + } +} diff --git a/packages/ai-persistence-drizzle/src/index.ts b/packages/ai-persistence-drizzle/src/index.ts new file mode 100644 index 000000000..6ace6326c --- /dev/null +++ b/packages/ai-persistence-drizzle/src/index.ts @@ -0,0 +1,47 @@ +/** + * Drizzle-backed persistence for TanStack AI (SQLite and Postgres). + * + * Schema-first: this package does **not** ship SQL migrations. Emit a schema + * into your project with `tanstack-ai-drizzle-schema`, let **your** drizzle-kit + * journal own the DDL, then pass the schema into {@link drizzlePersistence} + * together with the matching `provider`. + * + * Package layout: + * - `core/` — shared stores, schema contract, persistence wiring + * - `sqlite/` — SQLite schema (source of truth), ensure, Node factory + * - `pg/` — Postgres schema (codegen from sqlite), ensure + * + * This root entry is safe to import in edge runtimes. Node's SQLite + * convenience factory (default schema + optional runtime table bootstrap) + * lives at `@tanstack/ai-persistence-drizzle/sqlite`. + */ +export { drizzlePersistence } from './core/persistence' +export type { + DrizzlePgDb, + DrizzlePersistence, + DrizzlePersistenceOptions, + PgPersistenceConfig, + SqlitePersistenceConfig, +} from './core/persistence' + +export { createDefaultSqliteSchema } from './sqlite/default-schema' +export { createDefaultPgSchema } from './pg/default-schema' +export { ensureSqliteTables } from './sqlite/ensure-tables' +export { ensurePgTables } from './pg/ensure-tables' +export { + drizzleSchemaFilename, + drizzleSchemaSources, + drizzleSchemaSource, +} from './core/schema-source' +export { + DrizzleSchemaError, + assertTanstackAiSchema, +} from './core/schema-contract' +export type { + DrizzleProvider, + TanstackAiPgSchema, + TanstackAiSqliteSchema, + TanstackAiTableShapes, + TanstackAiSchema, +} from './core/schema-contract' +export type { DrizzleSqliteDb, DrizzleDb } from './core/stores' diff --git a/packages/ai-persistence-drizzle/src/pg/assets/tanstack-ai-schema.ts b/packages/ai-persistence-drizzle/src/pg/assets/tanstack-ai-schema.ts new file mode 100644 index 000000000..d760940dc --- /dev/null +++ b/packages/ai-persistence-drizzle/src/pg/assets/tanstack-ai-schema.ts @@ -0,0 +1,96 @@ +/** + * GENERATED by scripts/codegen-pg-from-sqlite.ts — do not edit by hand. + * Source: src/sqlite/assets/tanstack-ai-schema.ts + * Regenerate: pnpm codegen:pg + */ + +/** + * TanStack AI persistence schema — emitted by `tanstack-ai-drizzle-schema --dialect pg`. + * + * This file is yours. Add it to your drizzle-kit `schema` paths so your own + * migration journal owns the DDL, then pass it back to the runtime: + * + * ```ts + * import { drizzlePersistence } from '@tanstack/ai-persistence-drizzle' + * import { schema } from './tanstack-ai-schema' + * + * const persistence = drizzlePersistence(db, { provider: 'pg', schema }) + * ``` + * + * You may rename tables and columns (or drop the explicit column names below + * and rely on your drizzle `casing` configuration) and add extra app-owned + * columns — keep added columns nullable or defaulted so the runtime's inserts + * succeed, and keep the columns below with these data shapes. + * + * This package does not ship SQL migrations. Generate and apply them with + * drizzle-kit in this project. + */ +import { + bigint, + index, + jsonb, + pgTable, + primaryKey, + text, +} from 'drizzle-orm/pg-core' +import type { InterruptRecord, RunStatus } from '@tanstack/ai-persistence' +import type { ModelMessage, TokenUsage } from '@tanstack/ai' + +/** Thread message history (`MessageStore`). */ +export const messages = pgTable('messages', { + threadId: text('thread_id').primaryKey(), + messagesJson: jsonb('messages_json').$type>().notNull(), +}) + +/** Run lifecycle records (`RunStore`). */ +export const runs = pgTable('runs', { + runId: text('run_id').primaryKey(), + threadId: text('thread_id').notNull(), + status: text('status').$type().notNull(), + startedAt: bigint('started_at', { mode: 'number' }).notNull(), + finishedAt: bigint('finished_at', { mode: 'number' }), + error: text('error'), + usageJson: jsonb('usage_json').$type(), +}) + +/** Interrupt / approval records (`InterruptStore`). */ +export const interrupts = pgTable( + 'interrupts', + { + interruptId: text('interrupt_id').primaryKey(), + runId: text('run_id').notNull(), + threadId: text('thread_id').notNull(), + status: text('status').$type().notNull(), + requestedAt: bigint('requested_at', { mode: 'number' }).notNull(), + resolvedAt: bigint('resolved_at', { mode: 'number' }), + payloadJson: jsonb('payload_json') + .$type>() + .notNull(), + responseJson: jsonb('response_json').$type(), + }, + // The runtime lists interrupts by thread and by run; keep (or extend) these + // lookup indexes to taste — this file is yours. + (table) => [ + index('interrupts_thread_id_idx').on(table.threadId), + index('interrupts_run_id_idx').on(table.runId), + ], +) + +/** Scoped key/value metadata (`MetadataStore`). */ +export const metadata = pgTable( + 'metadata', + { + scope: text('scope').notNull(), + key: text('key').notNull(), + valueJson: jsonb('value_json').$type().notNull(), + }, + (table) => [primaryKey({ columns: [table.scope, table.key] })], +) + +/** The full state schema, for `drizzlePersistence(db, { provider: 'pg', schema })` and drizzle-kit. */ +export const schema = { + messages, + runs, + interrupts, + metadata, +} diff --git a/packages/ai-persistence-drizzle/src/pg/default-schema.ts b/packages/ai-persistence-drizzle/src/pg/default-schema.ts new file mode 100644 index 000000000..abd7ac4b2 --- /dev/null +++ b/packages/ai-persistence-drizzle/src/pg/default-schema.ts @@ -0,0 +1,95 @@ +/** + * GENERATED by scripts/codegen-pg-from-sqlite.ts — do not edit by hand. + * Source: src/sqlite/default-schema.ts + * Regenerate: pnpm codegen:pg + */ + +/** + * Default Postgres table definitions for TanStack AI state stores. + * + * Prefer owning this shape in your project via `tanstack-ai-drizzle-schema --dialect pg` and + * generating DDL with **your** drizzle-kit journal. This factory is the runtime + * convenience default for apps that want + * the stock tables without copying a file first. + * + * This package does **not** ship SQL migrations. Schema ownership and + * migration generation live in the application. + */ +import { + bigint, + index, + jsonb, + pgTable, + primaryKey, + text, +} from 'drizzle-orm/pg-core' +import type { InterruptRecord, RunStatus } from '@tanstack/ai-persistence' +import type { ModelMessage, TokenUsage } from '@tanstack/ai' +import type { TanstackAiPgSchema } from '../core/schema-contract' + +/** Thread message history (`MessageStore`). */ +export const messages = pgTable('messages', { + threadId: text('thread_id').primaryKey(), + messagesJson: jsonb('messages_json').$type>().notNull(), +}) + +/** Run lifecycle records (`RunStore`). */ +export const runs = pgTable('runs', { + runId: text('run_id').primaryKey(), + threadId: text('thread_id').notNull(), + status: text('status').$type().notNull(), + startedAt: bigint('started_at', { mode: 'number' }).notNull(), + finishedAt: bigint('finished_at', { mode: 'number' }), + error: text('error'), + usageJson: jsonb('usage_json').$type(), +}) + +/** Interrupt / approval records (`InterruptStore`). */ +export const interrupts = pgTable( + 'interrupts', + { + interruptId: text('interrupt_id').primaryKey(), + runId: text('run_id').notNull(), + threadId: text('thread_id').notNull(), + status: text('status').$type().notNull(), + requestedAt: bigint('requested_at', { mode: 'number' }).notNull(), + resolvedAt: bigint('resolved_at', { mode: 'number' }), + payloadJson: jsonb('payload_json') + .$type>() + .notNull(), + responseJson: jsonb('response_json').$type(), + }, + // The stores list interrupts by thread and by run (`list*`, + // `listPending*`); index both foreign lookups. + (table) => [ + index('interrupts_thread_id_idx').on(table.threadId), + index('interrupts_run_id_idx').on(table.runId), + ], +) + +/** Scoped key/value metadata (`MetadataStore`). */ +export const metadata = pgTable( + 'metadata', + { + scope: text('scope').notNull(), + key: text('key').notNull(), + valueJson: jsonb('value_json').$type().notNull(), + }, + (table) => [primaryKey({ columns: [table.scope, table.key] })], +) + +/** + * Build a fresh copy of the default SQLite schema tables. + * + * Pass the result to {@link drizzlePersistence} with `provider: 'pg'`, + * or prefer the file emitted by `tanstack-ai-drizzle-schema --dialect pg` when you want + * drizzle-kit to own DDL in your repo. + */ +export function createDefaultPgSchema(): TanstackAiPgSchema { + return { + messages, + runs, + interrupts, + metadata, + } +} diff --git a/packages/ai-persistence-drizzle/src/pg/ensure-tables.ts b/packages/ai-persistence-drizzle/src/pg/ensure-tables.ts new file mode 100644 index 000000000..921c6f1cd --- /dev/null +++ b/packages/ai-persistence-drizzle/src/pg/ensure-tables.ts @@ -0,0 +1,69 @@ +/** + * Derive `CREATE TABLE IF NOT EXISTS` statements from a user-supplied (or + * default) Postgres schema. A local/dev bootstrap convenience — production + * apps should emit the schema with `tanstack-ai-drizzle-schema --dialect pg` + * and generate migrations via their own drizzle-kit journal. + */ +import { getTableConfig } from 'drizzle-orm/pg-core' +import type { PgTable } from 'drizzle-orm/pg-core' +import type { TanstackAiPgSchema } from '../core/schema-contract' + +function quoteIdent(name: string): string { + return `"${name.replaceAll('"', '""')}"` +} + +function createTableSql(table: PgTable): string { + const config = getTableConfig(table) + const columnSql = config.columns.map((column) => { + const parts = [`${quoteIdent(column.name)} ${column.getSQLType()}`] + if (column.primary) parts.push('PRIMARY KEY') + if (column.notNull && !column.primary) parts.push('NOT NULL') + return parts.join(' ') + }) + + for (const primaryKey of config.primaryKeys) { + const cols = primaryKey.columns.map((column) => quoteIdent(column.name)) + columnSql.push(`PRIMARY KEY(${cols.join(', ')})`) + } + + return `CREATE TABLE IF NOT EXISTS ${quoteIdent(config.name)} (${columnSql.join(', ')})` +} + +function createIndexSql(table: PgTable): Array { + const config = getTableConfig(table) + const statements: Array = [] + for (const index of config.indexes) { + const name = index.config.name + // Index columns are `IndexedColumn` wrappers (or raw SQL, which a + // bootstrap can't reproduce); emit only plain named-column indexes and + // leave anything fancier to drizzle-kit migrations. + const cols = index.config.columns + .map((column) => + 'name' in column && typeof column.name === 'string' + ? quoteIdent(column.name) + : null, + ) + .filter((column): column is string => column !== null) + if (!name || cols.length !== index.config.columns.length) continue + const unique = index.config.unique ? 'UNIQUE ' : '' + statements.push( + `CREATE ${unique}INDEX IF NOT EXISTS ${quoteIdent(name)} ON ${quoteIdent(config.name)} (${cols.join(', ')})`, + ) + } + return statements +} + +/** + * Create missing tables (and their indexes) for every table in `schema`. + * Idempotent. Does not alter existing tables — use drizzle-kit migrations for + * evolution. + */ +export async function ensurePgTables( + exec: (sql: string) => Promise | unknown, + schema: TanstackAiPgSchema, +): Promise { + for (const table of Object.values(schema)) { + await exec(createTableSql(table)) + for (const indexSql of createIndexSql(table)) await exec(indexSql) + } +} diff --git a/packages/ai-persistence-drizzle/src/schema-cli-main.ts b/packages/ai-persistence-drizzle/src/schema-cli-main.ts new file mode 100644 index 000000000..dbb0c36ff --- /dev/null +++ b/packages/ai-persistence-drizzle/src/schema-cli-main.ts @@ -0,0 +1,10 @@ +#!/usr/bin/env node +import { runDrizzleSchemaCli } from './core/schema-cli' + +try { + await runDrizzleSchemaCli(process.argv.slice(2)) +} catch (error) { + const message = error instanceof Error ? error.message : String(error) + process.stderr.write(`${message}\n`) + process.exitCode = 1 +} diff --git a/packages/ai-persistence-drizzle/src/sqlite.ts b/packages/ai-persistence-drizzle/src/sqlite.ts new file mode 100644 index 000000000..30d68a3ef --- /dev/null +++ b/packages/ai-persistence-drizzle/src/sqlite.ts @@ -0,0 +1,11 @@ +/** + * Public entry for `@tanstack/ai-persistence-drizzle/sqlite`. + * + * Implementation lives in `sqlite/factory.ts`. + */ +export { + createDefaultSqliteSchema, + ensureSqliteTables, + sqlitePersistence, +} from './sqlite/factory' +export type { SqlitePersistenceOptions } from './sqlite/factory' diff --git a/packages/ai-persistence-drizzle/src/sqlite/assets/tanstack-ai-schema.ts b/packages/ai-persistence-drizzle/src/sqlite/assets/tanstack-ai-schema.ts new file mode 100644 index 000000000..c2b6a6fbe --- /dev/null +++ b/packages/ai-persistence-drizzle/src/sqlite/assets/tanstack-ai-schema.ts @@ -0,0 +1,94 @@ +/** + * TanStack AI persistence schema — emitted by `tanstack-ai-drizzle-schema`. + * + * This file is yours. Add it to your drizzle-kit `schema` paths so your own + * migration journal owns the DDL, then pass it back to the runtime: + * + * ```ts + * import { drizzlePersistence } from '@tanstack/ai-persistence-drizzle' + * import { schema } from './tanstack-ai-schema' + * + * const persistence = drizzlePersistence(db, { provider: 'sqlite', schema }) + * ``` + * + * You may rename tables and columns (or drop the explicit column names below + * and rely on your drizzle `casing` configuration) and add extra app-owned + * columns — keep added columns nullable or defaulted so the runtime's inserts + * succeed, and keep the columns below with these data shapes. + * + * This package does not ship SQL migrations. Generate and apply them with + * drizzle-kit in this project. + * + * **Source of truth for the Postgres CLI asset** — edit this file, then run + * `pnpm codegen:pg` to regenerate `src/pg/assets/tanstack-ai-schema.ts`. + */ +import { + index, + integer, + primaryKey, + sqliteTable, + text, +} from 'drizzle-orm/sqlite-core' +import type { InterruptRecord, RunStatus } from '@tanstack/ai-persistence' +import type { ModelMessage, TokenUsage } from '@tanstack/ai' + +/** Thread message history (`MessageStore`). */ +export const messages = sqliteTable('messages', { + threadId: text('thread_id').primaryKey(), + messagesJson: text('messages_json', { mode: 'json' }) + .$type>() + .notNull(), +}) + +/** Run lifecycle records (`RunStore`). */ +export const runs = sqliteTable('runs', { + runId: text('run_id').primaryKey(), + threadId: text('thread_id').notNull(), + status: text('status').$type().notNull(), + startedAt: integer('started_at').notNull(), + finishedAt: integer('finished_at'), + error: text('error'), + usageJson: text('usage_json', { mode: 'json' }).$type(), +}) + +/** Interrupt / approval records (`InterruptStore`). */ +export const interrupts = sqliteTable( + 'interrupts', + { + interruptId: text('interrupt_id').primaryKey(), + runId: text('run_id').notNull(), + threadId: text('thread_id').notNull(), + status: text('status').$type().notNull(), + requestedAt: integer('requested_at').notNull(), + resolvedAt: integer('resolved_at'), + payloadJson: text('payload_json', { mode: 'json' }) + .$type>() + .notNull(), + responseJson: text('response_json', { mode: 'json' }).$type(), + }, + // The runtime lists interrupts by thread and by run; keep (or extend) these + // lookup indexes to taste — this file is yours. + (table) => [ + index('interrupts_thread_id_idx').on(table.threadId), + index('interrupts_run_id_idx').on(table.runId), + ], +) + +/** Scoped key/value metadata (`MetadataStore`). */ +export const metadata = sqliteTable( + 'metadata', + { + scope: text('scope').notNull(), + key: text('key').notNull(), + valueJson: text('value_json', { mode: 'json' }).$type().notNull(), + }, + (table) => [primaryKey({ columns: [table.scope, table.key] })], +) + +/** The full state schema, for `drizzlePersistence(db, { schema })` and drizzle-kit. */ +export const schema = { + messages, + runs, + interrupts, + metadata, +} diff --git a/packages/ai-persistence-drizzle/src/sqlite/default-schema.ts b/packages/ai-persistence-drizzle/src/sqlite/default-schema.ts new file mode 100644 index 000000000..316ae479c --- /dev/null +++ b/packages/ai-persistence-drizzle/src/sqlite/default-schema.ts @@ -0,0 +1,93 @@ +/** + * Default SQLite table definitions for TanStack AI state stores. + * + * Prefer owning this shape in your project via `tanstack-ai-drizzle-schema` and + * generating DDL with **your** drizzle-kit journal. This factory is the runtime + * convenience default used by {@link sqlitePersistence} and by apps that want + * the stock tables without copying a file first. + * + * This package does **not** ship SQL migrations. Schema ownership and + * migration generation live in the application. + * + * **Source of truth for the Postgres projection** — edit this file (and the + * SQLite CLI asset), then run `pnpm codegen:pg` to regenerate `src/pg/`. + */ +import { + index, + integer, + primaryKey, + sqliteTable, + text, +} from 'drizzle-orm/sqlite-core' +import type { InterruptRecord, RunStatus } from '@tanstack/ai-persistence' +import type { ModelMessage, TokenUsage } from '@tanstack/ai' +import type { TanstackAiSqliteSchema } from '../core/schema-contract' + +/** Thread message history (`MessageStore`). */ +export const messages = sqliteTable('messages', { + threadId: text('thread_id').primaryKey(), + messagesJson: text('messages_json', { mode: 'json' }) + .$type>() + .notNull(), +}) + +/** Run lifecycle records (`RunStore`). */ +export const runs = sqliteTable('runs', { + runId: text('run_id').primaryKey(), + threadId: text('thread_id').notNull(), + status: text('status').$type().notNull(), + startedAt: integer('started_at').notNull(), + finishedAt: integer('finished_at'), + error: text('error'), + usageJson: text('usage_json', { mode: 'json' }).$type(), +}) + +/** Interrupt / approval records (`InterruptStore`). */ +export const interrupts = sqliteTable( + 'interrupts', + { + interruptId: text('interrupt_id').primaryKey(), + runId: text('run_id').notNull(), + threadId: text('thread_id').notNull(), + status: text('status').$type().notNull(), + requestedAt: integer('requested_at').notNull(), + resolvedAt: integer('resolved_at'), + payloadJson: text('payload_json', { mode: 'json' }) + .$type>() + .notNull(), + responseJson: text('response_json', { mode: 'json' }).$type(), + }, + // The stores list interrupts by thread and by run (`list*`, + // `listPending*`); index both foreign lookups. + (table) => [ + index('interrupts_thread_id_idx').on(table.threadId), + index('interrupts_run_id_idx').on(table.runId), + ], +) + +/** Scoped key/value metadata (`MetadataStore`). */ +export const metadata = sqliteTable( + 'metadata', + { + scope: text('scope').notNull(), + key: text('key').notNull(), + valueJson: text('value_json', { mode: 'json' }).$type().notNull(), + }, + (table) => [primaryKey({ columns: [table.scope, table.key] })], +) + +/** + * Build a fresh copy of the default SQLite schema tables. + * + * Pass the result to {@link drizzlePersistence} or {@link sqlitePersistence}, + * or prefer the file emitted by `tanstack-ai-drizzle-schema` when you want + * drizzle-kit to own DDL in your repo. + */ +export function createDefaultSqliteSchema(): TanstackAiSqliteSchema { + return { + messages, + runs, + interrupts, + metadata, + } +} diff --git a/packages/ai-persistence-drizzle/src/sqlite/ensure-tables.ts b/packages/ai-persistence-drizzle/src/sqlite/ensure-tables.ts new file mode 100644 index 000000000..9e32765e7 --- /dev/null +++ b/packages/ai-persistence-drizzle/src/sqlite/ensure-tables.ts @@ -0,0 +1,71 @@ +/** + * Derive `CREATE TABLE IF NOT EXISTS` statements from a user-supplied (or + * default) SQLite schema. Used by the Node convenience factory so local/dev + * can bootstrap without shipping versioned migrations from this package. + * + * Production apps should emit the schema with `tanstack-ai-drizzle-schema` and + * generate migrations via their own drizzle-kit journal instead of relying on + * runtime table creation for schema evolution. + */ +import { getTableConfig } from 'drizzle-orm/sqlite-core' +import type { SQLiteTable } from 'drizzle-orm/sqlite-core' +import type { TanstackAiSqliteSchema } from '../core/schema-contract' + +function quoteIdent(name: string): string { + return `"${name.replaceAll('"', '""')}"` +} + +function createTableSql(table: SQLiteTable): string { + const config = getTableConfig(table) + const columnSql = config.columns.map((column) => { + const parts = [`${quoteIdent(column.name)} ${column.getSQLType()}`] + if (column.primary) parts.push('PRIMARY KEY') + if (column.notNull && !column.primary) parts.push('NOT NULL') + return parts.join(' ') + }) + + for (const primaryKey of config.primaryKeys) { + const cols = primaryKey.columns.map((column) => quoteIdent(column.name)) + columnSql.push(`PRIMARY KEY(${cols.join(', ')})`) + } + + return `CREATE TABLE IF NOT EXISTS ${quoteIdent(config.name)} (${columnSql.join(', ')})` +} + +function createIndexSql(table: SQLiteTable): Array { + const config = getTableConfig(table) + const statements: Array = [] + for (const index of config.indexes) { + const name = index.config.name + // Emit only plain named-column indexes (not raw SQL expressions); leave + // anything fancier to drizzle-kit migrations. + const cols = index.config.columns + .map((column) => + 'name' in column && typeof column.name === 'string' + ? quoteIdent(column.name) + : null, + ) + .filter((column): column is string => column !== null) + if (!name || cols.length !== index.config.columns.length) continue + const unique = index.config.unique ? 'UNIQUE ' : '' + statements.push( + `CREATE ${unique}INDEX IF NOT EXISTS ${quoteIdent(name)} ON ${quoteIdent(config.name)} (${cols.join(', ')})`, + ) + } + return statements +} + +/** + * Create missing tables (and their indexes) for every table in `schema`. + * Idempotent. Does not alter existing tables — use drizzle-kit migrations for + * evolution. + */ +export function ensureSqliteTables( + exec: (sql: string) => void, + schema: TanstackAiSqliteSchema, +): void { + for (const table of Object.values(schema)) { + exec(createTableSql(table)) + for (const indexSql of createIndexSql(table)) exec(indexSql) + } +} diff --git a/packages/ai-persistence-drizzle/src/sqlite/factory.ts b/packages/ai-persistence-drizzle/src/sqlite/factory.ts new file mode 100644 index 000000000..d5d8596c8 --- /dev/null +++ b/packages/ai-persistence-drizzle/src/sqlite/factory.ts @@ -0,0 +1,136 @@ +/** + * Node-only SQLite convenience factory for TanStack AI persistence. + * + * Schema-first: pass your schema (or accept {@link createDefaultSqliteSchema}), + * and optionally bootstrap tables from that schema at runtime. This package + * does **not** ship versioned SQL migrations — production apps should emit the + * schema with `tanstack-ai-drizzle-schema` and migrate via their own drizzle-kit + * journal. + * + * Uses Node's built-in `node:sqlite` (`DatabaseSync`). That module is still a + * release candidate in some Node versions; pin a release that documents the API + * you rely on, or use {@link drizzlePersistence} with a stable driver. + */ +import { mkdirSync } from 'node:fs' +import { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { DatabaseSync } from 'node:sqlite' +import { drizzle } from 'drizzle-orm/sqlite-proxy' +import { drizzlePersistence } from '../core/persistence' +import { createDefaultSqliteSchema } from './default-schema' +import { ensureSqliteTables } from './ensure-tables' +import type { ChatPersistence } from '@tanstack/ai-persistence' +import type { TanstackAiSqliteSchema } from '../core/schema-contract' + +export { createDefaultSqliteSchema } from './default-schema' +export { ensureSqliteTables } from './ensure-tables' + +export interface SqlitePersistenceOptions { + /** `:memory:`, a filesystem path, or a `file:`-prefixed filesystem path. */ + url: string + /** + * Schema tables the stores operate on. Defaults to + * {@link createDefaultSqliteSchema}. Prefer a project-owned copy emitted by + * `tanstack-ai-drizzle-schema` when you use drizzle-kit for migrations. + */ + schema?: TanstackAiSqliteSchema + /** + * When true (default), create missing tables derived from `schema` via + * `CREATE TABLE IF NOT EXISTS`. This is a local bootstrap convenience, not a + * migration system — set `false` when your drizzle-kit migrations already + * created the tables. + */ + ensureTables?: boolean +} + +/** + * Build persistence over Node's built-in SQLite driver with stock defaults. + * + * @example Zero-config local file + * ```ts + * const persistence = sqlitePersistence({ + * url: 'file:.data/ai.sqlite', + * }) + * ``` + * + * @example Project-owned schema (after `tanstack-ai-drizzle-schema` + kit migrate) + * ```ts + * import { schema } from './db/tanstack-ai-schema' + * + * const persistence = sqlitePersistence({ + * url: 'file:.data/ai.sqlite', + * schema, + * ensureTables: false, + * }) + * ``` + */ +export function sqlitePersistence( + options: SqlitePersistenceOptions, +): ChatPersistence & { + /** Close the underlying Node SQLite connection. */ + close: () => void +} { + const schema = options.schema ?? createDefaultSqliteSchema() + const filename = normalizeSqliteUrl(options.url) + ensureParentDirectory(filename) + const sqlite = new DatabaseSync(filename) + try { + if (options.ensureTables !== false) { + ensureSqliteTables((sql) => sqlite.exec(sql), schema) + } + } catch (error) { + sqlite.close() + throw error + } + + const db = drizzle((sql, params, method) => { + const statement = sqlite.prepare(sql) + if (method === 'run') { + statement.run(...params) + return Promise.resolve({ rows: [] }) + } + if (method === 'get') { + const row = statement.get(...params) + return Promise.resolve({ rows: row ? Object.values(row) : [] }) + } + const rows = statement.all(...params) + return Promise.resolve({ rows: rows.map((row) => Object.values(row)) }) + }) + + const persistence = drizzlePersistence(db, { provider: 'sqlite', schema }) + let closed = false + return { + ...persistence, + close() { + if (closed) return + sqlite.close() + closed = true + }, + } +} + +function normalizeSqliteUrl(url: string): string { + if (url === ':memory:' || url === 'file::memory:') return ':memory:' + if (url.startsWith('file://')) return validateFilename(fileURLToPath(url)) + if (url.startsWith('file:')) { + return validateFilename(url.slice('file:'.length)) + } + const isWindowsPath = /^[A-Za-z]:[\\/]/.test(url) + if (!isWindowsPath && /^[A-Za-z][A-Za-z\d+.-]*:/.test(url)) { + throw new Error(`Unsupported SQLite URL scheme: ${url}`) + } + return validateFilename(url) +} + +function validateFilename(filename: string): string { + if (filename.length === 0 || filename.includes('\0')) { + throw new Error('SQLite URL must identify a non-empty filesystem path') + } + return filename +} + +function ensureParentDirectory(filename: string): void { + if (filename === ':memory:') return + const parent = dirname(filename) + if (parent !== '.') mkdirSync(parent, { recursive: true }) +} diff --git a/packages/ai-persistence-drizzle/tests/api-types.test-d.ts b/packages/ai-persistence-drizzle/tests/api-types.test-d.ts new file mode 100644 index 000000000..685b275ac --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/api-types.test-d.ts @@ -0,0 +1,68 @@ +import { expectTypeOf } from 'vitest' +import type { DrizzleD1Database } from 'drizzle-orm/d1' +import type { PgliteDatabase } from 'drizzle-orm/pglite' +import type { + ChatPersistence, + MessageStore, + RunStore, +} from '@tanstack/ai-persistence' +import { + createDefaultPgSchema, + createDefaultSqliteSchema, + drizzlePersistence, +} from '../src/index' +import { sqlitePersistence } from '../src/sqlite' +import { schema as emittedSchema } from '../src/sqlite/assets/tanstack-ai-schema' +import { schema as emittedPgSchema } from '../src/pg/assets/tanstack-ai-schema' +import { variantSchema } from './variant-schema' +import type { TanstackAiPgSchema, TanstackAiSqliteSchema } from '../src/index' + +declare const d1Database: DrizzleD1Database +declare const pgDatabase: PgliteDatabase +const defaultSchema = createDefaultSqliteSchema() +const defaultPgSchema = createDefaultPgSchema() +const d1Persistence = drizzlePersistence(d1Database, { + provider: 'sqlite', + schema: defaultSchema, +}) +expectTypeOf(d1Persistence).toEqualTypeOf() +expectTypeOf(d1Persistence.stores.messages).toEqualTypeOf() +expectTypeOf(d1Persistence.stores.runs).toEqualTypeOf() +// State bag only — locks are provided separately via withLocks. +expectTypeOf(d1Persistence.stores).not.toHaveProperty('locks') + +const pgPersistence = drizzlePersistence(pgDatabase, { + provider: 'pg', + schema: defaultPgSchema, +}) +expectTypeOf(pgPersistence).toEqualTypeOf() + +// Provider, db, and schema dialects must agree. +// @ts-expect-error sqlite db cannot be used with provider 'pg' +drizzlePersistence(d1Database, { provider: 'pg', schema: defaultPgSchema }) +// @ts-expect-error pg db cannot be used with provider 'sqlite' +drizzlePersistence(pgDatabase, { provider: 'sqlite', schema: defaultSchema }) +// @ts-expect-error pg schema cannot be used with provider 'sqlite' +drizzlePersistence(d1Database, { provider: 'sqlite', schema: defaultPgSchema }) +// @ts-expect-error sqlite schema cannot be used with provider 'pg' +drizzlePersistence(pgDatabase, { provider: 'pg', schema: defaultSchema }) +// @ts-expect-error provider is required +drizzlePersistence(d1Database, { schema: defaultSchema }) + +const sqlite = sqlitePersistence({ url: ':memory:' }) +expectTypeOf(sqlite.stores).toEqualTypeOf() +expectTypeOf(sqlite.close).toEqualTypeOf<() => void>() + +// Default factories, emitted assets, and renamed/extended variant all satisfy +// the injectable schema contracts. +expectTypeOf(defaultSchema).toExtend() +expectTypeOf(emittedSchema).toExtend() +expectTypeOf(variantSchema).toExtend() +expectTypeOf(defaultPgSchema).toExtend() +expectTypeOf(emittedPgSchema).toExtend() + +const customPersistence = drizzlePersistence(d1Database, { + provider: 'sqlite', + schema: variantSchema, +}) +expectTypeOf(customPersistence).toEqualTypeOf() diff --git a/packages/ai-persistence-drizzle/tests/custom-schema.test.ts b/packages/ai-persistence-drizzle/tests/custom-schema.test.ts new file mode 100644 index 000000000..cf657272c --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/custom-schema.test.ts @@ -0,0 +1,139 @@ +import { DatabaseSync } from 'node:sqlite' +import { describe, expect, it } from 'vitest' +import { eq } from 'drizzle-orm' +import { sqliteTable, text } from 'drizzle-orm/sqlite-core' +import { drizzle } from 'drizzle-orm/sqlite-proxy' +import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' +import { + createDefaultPgSchema, + createDefaultSqliteSchema, + DrizzleSchemaError, + drizzlePersistence, +} from '../src/index' +import { variantDdl, variantSchema } from './variant-schema' +import type { TanstackAiSqliteSchema } from '../src/index' +import type { DrizzleSqliteDb } from '../src/index' + +function createVariantDb(): { db: DrizzleSqliteDb; sqlite: DatabaseSync } { + const sqlite = new DatabaseSync(':memory:') + for (const statement of variantDdl) sqlite.exec(statement) + const db = drizzle((sql, params, method) => { + const statement = sqlite.prepare(sql) + if (method === 'run') { + statement.run(...params) + return Promise.resolve({ rows: [] }) + } + if (method === 'get') { + const row = statement.get(...params) + return Promise.resolve({ rows: row ? Object.values(row) : [] }) + } + const rows = statement.all(...params) + return Promise.resolve({ rows: rows.map((row) => Object.values(row)) }) + }) + return { db, sqlite } +} + +// The full store contract must hold when the runtime operates over a schema +// whose table and column database names all differ from the defaults. +runPersistenceConformance('drizzle-sqlite (injected variant schema)', () => + drizzlePersistence(createVariantDb().db, { + provider: 'sqlite', + schema: variantSchema, + }), +) + +describe('drizzlePersistence with an injected schema', () => { + it('writes through the injected table and column names', async () => { + const { db, sqlite } = createVariantDb() + const persistence = drizzlePersistence(db, { + provider: 'sqlite', + schema: variantSchema, + }) + + await persistence.stores.messages.saveThread('thread-1', [ + { role: 'user', content: 'hello' }, + ]) + + const row = sqlite + .prepare('SELECT threadId, messagesJson FROM app_ai_messages') + .get() + expect(row?.threadId).toBe('thread-1') + expect(JSON.parse(String(row?.messagesJson))).toEqual([ + { role: 'user', content: 'hello' }, + ]) + }) + + it('coexists with app-owned columns on the same tables', async () => { + const { db, sqlite } = createVariantDb() + const persistence = drizzlePersistence(db, { + provider: 'sqlite', + schema: variantSchema, + }) + + await persistence.stores.messages.saveThread('thread-owned', [ + { role: 'user', content: 'mine' }, + ]) + // The app claims ownership through its own extension column. + await db + .update(variantSchema.messages) + .set({ userId: 'user-42' }) + .where(eq(variantSchema.messages.threadId, 'thread-owned')) + + // Store updates leave the app-owned column untouched. + await persistence.stores.messages.saveThread('thread-owned', [ + { role: 'user', content: 'mine' }, + { role: 'assistant', content: 'yours' }, + ]) + + const row = sqlite + .prepare('SELECT userId FROM app_ai_messages WHERE threadId = ?') + .get('thread-owned') + expect(row?.userId).toBe('user-42') + expect( + await persistence.stores.messages.loadThread('thread-owned'), + ).toHaveLength(2) + }) + + it('rejects a schema with missing tables or columns', () => { + const { db } = createVariantDb() + const defaults = createDefaultSqliteSchema() + + const { metadata: _dropped, ...withoutMetadata } = defaults + expect(() => + drizzlePersistence(db, { + provider: 'sqlite', + schema: withoutMetadata as unknown as TanstackAiSqliteSchema, + }), + ).toThrow(DrizzleSchemaError) + expect(() => + drizzlePersistence(db, { + provider: 'sqlite', + schema: withoutMetadata as unknown as TanstackAiSqliteSchema, + }), + ).toThrow(/`metadata` is not a Drizzle SQLite table \(provider 'sqlite'\)/) + + const incompleteMessages = sqliteTable('messages', { + threadId: text('thread_id').primaryKey(), + }) + expect(() => + drizzlePersistence(db, { + provider: 'sqlite', + schema: { + ...defaults, + messages: incompleteMessages, + } as unknown as TanstackAiSqliteSchema, + }), + ).toThrow(/`messages\.messagesJson` is missing/) + }) + + it('rejects a schema whose tables are the wrong dialect for the provider', () => { + const { db } = createVariantDb() + const pgDefaults = createDefaultPgSchema() + expect(() => + drizzlePersistence(db, { + provider: 'sqlite', + schema: pgDefaults as unknown as TanstackAiSqliteSchema, + }), + ).toThrow(/`messages` is not a Drizzle SQLite table \(provider 'sqlite'\)/) + }) +}) diff --git a/packages/ai-persistence-drizzle/tests/drizzle.conformance.test.ts b/packages/ai-persistence-drizzle/tests/drizzle.conformance.test.ts new file mode 100644 index 000000000..572de7e9e --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/drizzle.conformance.test.ts @@ -0,0 +1,6 @@ +import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' +import { sqlitePersistence } from '../src/sqlite' + +runPersistenceConformance('drizzle-sqlite', () => + sqlitePersistence({ url: ':memory:' }), +) diff --git a/packages/ai-persistence-drizzle/tests/package-contract.test.ts b/packages/ai-persistence-drizzle/tests/package-contract.test.ts new file mode 100644 index 000000000..9681d3320 --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/package-contract.test.ts @@ -0,0 +1,91 @@ +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { describe, expect, it } from 'vitest' +import packageJson from '../package.json' + +const execFileAsync = promisify(execFile) + +describe('drizzle package contract', () => { + it('publishes the edge root, Node sqlite subpath, and schema-emit CLI only', () => { + expect(packageJson.exports).toEqual({ + '.': { + types: './dist/esm/index.d.ts', + import: './dist/esm/index.js', + }, + './sqlite': { + types: './dist/esm/sqlite.d.ts', + import: './dist/esm/sqlite.js', + }, + './sqlite-schema': { + types: './dist/esm/sqlite/default-schema.d.ts', + import: './dist/esm/sqlite/default-schema.js', + }, + './pg-schema': { + types: './dist/esm/pg/default-schema.d.ts', + import: './dist/esm/pg/default-schema.js', + }, + }) + expect(packageJson.bin).toEqual({ + 'tanstack-ai-drizzle-schema': './bin/tanstack-ai-drizzle-schema.mjs', + }) + expect(packageJson.files).toEqual(['bin', 'dist', 'src', 'scripts']) + expect(packageJson.bin).not.toHaveProperty('tanstack-ai-drizzle-migrations') + expect(packageJson.description.toLowerCase()).toMatch(/schema-first/) + expect(packageJson.scripts).toMatchObject({ + 'codegen:pg': 'node ./scripts/codegen-pg-from-sqlite.ts', + 'codegen:pg:check': 'node ./scripts/codegen-pg-from-sqlite.ts --check', + }) + }) + + it('does not ship SQL migrations or drizzle-kit journals', async () => { + const root = fileURLToPath(new URL('..', import.meta.url)) + await expect( + readFile(`${root}/src/assets/0000_tanstack_ai_initial.sql`, 'utf8'), + ).rejects.toMatchObject({ code: 'ENOENT' }) + await expect( + readFile(`${root}/drizzle/0000_tanstack_ai_initial.sql`, 'utf8'), + ).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('keeps Node built-ins and Buffer out of the root import graph', async () => { + const rootFiles = [ + 'index.ts', + 'core/persistence.ts', + 'core/schema-contract.ts', + 'core/schema-source.ts', + 'core/stores.ts', + 'sqlite/default-schema.ts', + 'sqlite/ensure-tables.ts', + 'pg/default-schema.ts', + 'pg/ensure-tables.ts', + ] + for (const filename of rootFiles) { + const contents = await readFile( + fileURLToPath(new URL(`../src/${filename}`, import.meta.url)), + 'utf8', + ) + expect(contents, filename).not.toMatch(/from ['"]node:/) + expect(contents, filename).not.toMatch(/\bBuffer\b/) + } + const root = await readFile( + fileURLToPath(new URL('../src/index.ts', import.meta.url)), + 'utf8', + ) + // Root must not pull the Node-only `/sqlite` entry or `node:sqlite`. + expect(root).not.toMatch(/from ['"]\.\/sqlite['"]/) + expect(root).not.toMatch(/from ['"]\.\/sqlite\/factory['"]/) + expect(root).not.toMatch(/from ['"]node:sqlite['"]/) + expect(root).not.toMatch(/sqliteMigrations/) + }) + + it('keeps Postgres schema modules in sync with SQLite sources', async () => { + const root = fileURLToPath(new URL('..', import.meta.url)) + await execFileAsync( + process.execPath, + ['./scripts/codegen-pg-from-sqlite.ts', '--check'], + { cwd: root }, + ) + }) +}) diff --git a/packages/ai-persistence-drizzle/tests/pg.conformance.test.ts b/packages/ai-persistence-drizzle/tests/pg.conformance.test.ts new file mode 100644 index 000000000..1748ce231 --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/pg.conformance.test.ts @@ -0,0 +1,23 @@ +import { PGlite } from '@electric-sql/pglite' +import { drizzle } from 'drizzle-orm/pglite' +import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' +import { afterAll } from 'vitest' +import { + createDefaultPgSchema, + drizzlePersistence, + ensurePgTables, +} from '../src/index' + +const clients: Array = [] + +runPersistenceConformance('drizzle-pg', async () => { + const client = new PGlite() + clients.push(client) + const schema = createDefaultPgSchema() + await ensurePgTables((sql) => client.exec(sql), schema) + return drizzlePersistence(drizzle(client), { provider: 'pg', schema }) +}) + +afterAll(async () => { + await Promise.all(clients.map((client) => client.close())) +}) diff --git a/packages/ai-persistence-drizzle/tests/schema-cli.test.ts b/packages/ai-persistence-drizzle/tests/schema-cli.test.ts new file mode 100644 index 000000000..5a6a457d1 --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/schema-cli.test.ts @@ -0,0 +1,99 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { drizzleSchemaFilename, drizzleSchemaSource } from '../src/index' +import { drizzleSchemaSources } from '../src/core/schema-source' +import { runDrizzleSchemaCli } from '../src/core/schema-cli' + +const temporaryDirectories: Array = [] + +async function createTemporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'tanstack-ai-drizzle-schema-')) + temporaryDirectories.push(directory) + return directory +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ) +}) + +describe('Drizzle schema CLI', () => { + it('prints the schema module to stdout', async () => { + let output = '' + await runDrizzleSchemaCli(['--stdout'], { + writeStdout(value) { + output += value + }, + }) + expect(output).toBe(`${drizzleSchemaSource.trimEnd()}\n`) + expect(output).toContain('sqliteTable') + expect(output).toContain( + "drizzlePersistence(db, { provider: 'sqlite', schema })", + ) + }) + + it('copies the schema without overwriting divergent files by default', async () => { + const directory = await createTemporaryDirectory() + await runDrizzleSchemaCli(['--out', directory]) + + const destination = join(directory, drizzleSchemaFilename) + expect(await readFile(destination, 'utf8')).toBe(drizzleSchemaSource) + + // Re-running against an identical file is a no-op. + await runDrizzleSchemaCli(['--out', directory]) + + await writeFile(destination, 'user-owned contents', 'utf8') + await expect(runDrizzleSchemaCli(['--out', directory])).rejects.toThrow( + /refusing to overwrite/i, + ) + expect(await readFile(destination, 'utf8')).toBe('user-owned contents') + + await runDrizzleSchemaCli(['--out', directory, '--force']) + expect(await readFile(destination, 'utf8')).toBe(drizzleSchemaSource) + }) + + it('emits the pg schema with --dialect pg', async () => { + let output = '' + await runDrizzleSchemaCli(['--stdout', '--dialect', 'pg'], { + writeStdout(value) { + output += value + }, + }) + expect(output).toBe(`${drizzleSchemaSources.pg.trimEnd()}\n`) + expect(output).toContain('pgTable') + expect(output).toContain( + "drizzlePersistence(db, { provider: 'pg', schema })", + ) + + const directory = await createTemporaryDirectory() + await runDrizzleSchemaCli(['--out', directory, '--dialect', 'pg']) + expect(await readFile(join(directory, drizzleSchemaFilename), 'utf8')).toBe( + drizzleSchemaSources.pg, + ) + }) + + it('rejects unknown or repeated dialects', async () => { + await expect( + runDrizzleSchemaCli(['--stdout', '--dialect', 'mysql']), + ).rejects.toThrow(/--dialect must be "sqlite" or "pg"/) + await expect( + runDrizzleSchemaCli(['--stdout', '--dialect', 'pg', '--dialect', 'pg']), + ).rejects.toThrow(/--dialect may only be provided once/) + }) + + it('fails on ambiguous or incomplete output options', async () => { + const directory = await createTemporaryDirectory() + await expect(runDrizzleSchemaCli([])).rejects.toThrow(/--out.*--stdout/i) + await expect( + runDrizzleSchemaCli(['--stdout', '--out', directory]), + ).rejects.toThrow(/exactly one/i) + await expect(runDrizzleSchemaCli(['--stdout', '--force'])).rejects.toThrow( + /--force can only be used with --out/i, + ) + }) +}) diff --git a/packages/ai-persistence-drizzle/tests/schema-source.test.ts b/packages/ai-persistence-drizzle/tests/schema-source.test.ts new file mode 100644 index 000000000..845814c25 --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/schema-source.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { getTableConfig } from 'drizzle-orm/sqlite-core' +import { getTableConfig as getPgTableConfig } from 'drizzle-orm/pg-core' +import { createDefaultSqliteSchema } from '../src/sqlite/default-schema' +import { createDefaultPgSchema } from '../src/pg/default-schema' +import { schema as emitted } from '../src/sqlite/assets/tanstack-ai-schema' +import { schema as emittedPg } from '../src/pg/assets/tanstack-ai-schema' +import type { PgTable } from 'drizzle-orm/pg-core' +import type { SQLiteTable } from 'drizzle-orm/sqlite-core' + +function normalizeConfig( + config: + | ReturnType + | ReturnType, +) { + return { + name: config.name, + columns: config.columns.map((column) => ({ + name: column.name, + sqlType: column.getSQLType(), + notNull: column.notNull, + primary: column.primary, + })), + primaryKeys: config.primaryKeys.map((primaryKey) => + primaryKey.columns.map((column) => column.name), + ), + indexes: config.indexes.map((index) => ({ + name: index.config.name, + unique: index.config.unique, + columns: index.config.columns.map((column) => + 'name' in column ? column.name : String(column), + ), + })), + } +} + +function normalize(table: SQLiteTable) { + return normalizeConfig(getTableConfig(table)) +} + +function normalizePg(table: PgTable) { + return normalizeConfig(getPgTableConfig(table)) +} + +describe('emitted schema asset', () => { + it('is structurally identical to createDefaultSqliteSchema()', () => { + const defaults = createDefaultSqliteSchema() + const tableKeys = Object.keys(defaults) as Array + expect(Object.keys(emitted)).toEqual(Object.keys(defaults)) + for (const key of tableKeys) { + expect(normalize(emitted[key]), key).toEqual(normalize(defaults[key])) + } + }) +}) + +describe('emitted pg schema asset', () => { + it('is structurally identical to createDefaultPgSchema()', () => { + const defaults = createDefaultPgSchema() + const tableKeys = Object.keys(defaults) as Array + expect(Object.keys(emittedPg)).toEqual(Object.keys(defaults)) + for (const key of tableKeys) { + expect(normalizePg(emittedPg[key]), key).toEqual( + normalizePg(defaults[key]), + ) + } + }) +}) diff --git a/packages/ai-persistence-drizzle/tests/sqlite.test.ts b/packages/ai-persistence-drizzle/tests/sqlite.test.ts new file mode 100644 index 000000000..55cc59aab --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/sqlite.test.ts @@ -0,0 +1,98 @@ +import { DatabaseSync } from 'node:sqlite' +import { mkdtemp, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { createDefaultSqliteSchema } from '../src/sqlite/default-schema' +import { ensureSqliteTables } from '../src/sqlite/ensure-tables' +import { sqlitePersistence } from '../src/sqlite' + +const temporaryDirectories: Array = [] + +afterEach(async () => { + for (const directory of temporaryDirectories.splice(0)) { + await rm(directory, { recursive: true, force: true }) + } +}) + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'tanstack-ai-sqlite-')) + temporaryDirectories.push(directory) + return directory +} + +describe('sqlitePersistence', () => { + it('creates a missing parent directory for a filesystem database', async () => { + const root = await temporaryDirectory() + const databasePath = join(root, 'nested', 'state.sqlite') + const persistence = sqlitePersistence({ + url: `file:${databasePath}`, + }) + + try { + await expect(stat(databasePath)).resolves.toMatchObject({ + isFile: expect.any(Function), + }) + } finally { + persistence.close() + } + }) + + it('exposes an idempotent close for the database handle it owns', () => { + const persistence = sqlitePersistence({ url: ':memory:' }) + + expect(() => { + persistence.close() + persistence.close() + }).not.toThrow() + }) + + it('rejects non-file URI schemes before touching the filesystem', () => { + expect(() => + sqlitePersistence({ url: 'https://example.test/state.sqlite' }), + ).toThrow(/unsupported SQLite URL/i) + }) + + it('bootstraps tables from the schema when ensureTables is true', async () => { + const persistence = sqlitePersistence({ url: ':memory:' }) + try { + await persistence.stores.messages.saveThread('t1', [ + { role: 'user', content: 'hi' }, + ]) + expect(await persistence.stores.messages.loadThread('t1')).toEqual([ + { role: 'user', content: 'hi' }, + ]) + } finally { + persistence.close() + } + }) + + it('skips runtime bootstrap when ensureTables is false', () => { + expect(() => + sqlitePersistence({ + url: ':memory:', + ensureTables: false, + }), + ).not.toThrow() + }) +}) + +describe('ensureSqliteTables', () => { + it('is idempotent for the default schema', () => { + const sqlite = new DatabaseSync(':memory:') + const schema = createDefaultSqliteSchema() + ensureSqliteTables((sql) => sqlite.exec(sql), schema) + ensureSqliteTables((sql) => sqlite.exec(sql), schema) + + const names = sqlite + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name", + ) + .all() + .map((row) => row.name) + expect(names).toEqual( + expect.arrayContaining(['interrupts', 'messages', 'metadata', 'runs']), + ) + sqlite.close() + }) +}) diff --git a/packages/ai-persistence-drizzle/tests/store-behavior.test.ts b/packages/ai-persistence-drizzle/tests/store-behavior.test.ts new file mode 100644 index 000000000..69d26704a --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/store-behavior.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { sqlitePersistence } from '../src/sqlite' + +type Persistence = ReturnType + +const open: Array = [] + +function persistence(): Persistence { + const created = sqlitePersistence({ url: ':memory:' }) + open.push(created) + return created +} + +afterEach(() => { + for (const created of open.splice(0)) created.close() +}) + +describe('drizzle metadata nullish values', () => { + it('rejects nullish values with a clear error instead of a NOT NULL crash', async () => { + const { stores } = persistence() + + // A NOT NULL json column cannot store nullish; surface a clear, actionable + // error (consistent with the Prisma backend) rather than a driver crash. + await expect(stores.metadata.set('scope', 'u', undefined)).rejects.toThrow( + /metadata values must be defined/, + ) + await expect(stores.metadata.set('scope', 'n', null)).rejects.toThrow( + /metadata values must be defined/, + ) + expect(await stores.metadata.get('scope', 'u')).toBeNull() + + // Falsy-but-defined values are stored and round-trip. + await stores.metadata.set('scope', 'zero', 0) + expect(await stores.metadata.get('scope', 'zero')).toBe(0) + await stores.metadata.set('scope', 'empty', '') + expect(await stores.metadata.get('scope', 'empty')).toBe('') + await stores.metadata.set('scope', 'obj', { a: 1 }) + expect(await stores.metadata.get('scope', 'obj')).toEqual({ a: 1 }) + }) +}) diff --git a/packages/ai-persistence-drizzle/tests/variant-schema.ts b/packages/ai-persistence-drizzle/tests/variant-schema.ts new file mode 100644 index 000000000..46551464d --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/variant-schema.ts @@ -0,0 +1,93 @@ +/** + * A deliberately renamed copy of the TanStack AI schema for exercising + * `drizzlePersistence(db, { schema })`: prefixed table names, camelCase column + * names (as a project using drizzle `casing`-style conventions would have), + * and an extra app-owned `userId` ownership column on the messages table. + * Structure (data shapes, nullability, primary keys) matches the contract. + */ +import { integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import type { InterruptRecord, RunStatus } from '@tanstack/ai-persistence' +import type { ModelMessage, TokenUsage } from '@tanstack/ai' + +const messages = sqliteTable('app_ai_messages', { + threadId: text('threadId').primaryKey(), + messagesJson: text('messagesJson', { mode: 'json' }) + .$type>() + .notNull(), + // App-owned extension the TanStack AI stores never read or write. + userId: text('userId'), +}) + +const runs = sqliteTable('app_ai_runs', { + runId: text('runId').primaryKey(), + threadId: text('threadId').notNull(), + status: text('status').$type().notNull(), + startedAt: integer('startedAt').notNull(), + finishedAt: integer('finishedAt'), + error: text('errorText'), + usageJson: text('usageJson', { mode: 'json' }).$type(), +}) + +const interrupts = sqliteTable('app_ai_interrupts', { + interruptId: text('interruptId').primaryKey(), + runId: text('runId').notNull(), + threadId: text('threadId').notNull(), + status: text('status').$type().notNull(), + requestedAt: integer('requestedAt').notNull(), + resolvedAt: integer('resolvedAt'), + payloadJson: text('payloadJson', { mode: 'json' }) + .$type>() + .notNull(), + responseJson: text('responseJson', { mode: 'json' }).$type(), +}) + +const metadata = sqliteTable( + 'app_ai_metadata', + { + scope: text('scopeName').notNull(), + key: text('keyName').notNull(), + valueJson: text('valueJson', { mode: 'json' }).$type().notNull(), + }, + (table) => [primaryKey({ columns: [table.scope, table.key] })], +) + +export const variantSchema = { + messages, + runs, + interrupts, + metadata, +} + +/** DDL matching {@link variantSchema}, applied directly in tests. */ +export const variantDdl: ReadonlyArray = [ + `CREATE TABLE app_ai_messages ( + threadId text PRIMARY KEY NOT NULL, + messagesJson text NOT NULL, + userId text + );`, + `CREATE TABLE app_ai_runs ( + runId text PRIMARY KEY NOT NULL, + threadId text NOT NULL, + status text NOT NULL, + startedAt integer NOT NULL, + finishedAt integer, + errorText text, + usageJson text + );`, + `CREATE TABLE app_ai_interrupts ( + interruptId text PRIMARY KEY NOT NULL, + runId text NOT NULL, + threadId text NOT NULL, + status text NOT NULL, + requestedAt integer NOT NULL, + resolvedAt integer, + payloadJson text NOT NULL, + responseJson text + );`, + `CREATE TABLE app_ai_metadata ( + scopeName text NOT NULL, + keyName text NOT NULL, + valueJson text NOT NULL, + PRIMARY KEY(scopeName, keyName) + );`, +] diff --git a/packages/ai-persistence-drizzle/tsconfig.json b/packages/ai-persistence-drizzle/tsconfig.json new file mode 100644 index 000000000..c38689f4e --- /dev/null +++ b/packages/ai-persistence-drizzle/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ai-persistence-drizzle/vite.config.ts b/packages/ai-persistence-drizzle/vite.config.ts new file mode 100644 index 000000000..2da5c7f0d --- /dev/null +++ b/packages/ai-persistence-drizzle/vite.config.ts @@ -0,0 +1,43 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + '**/types.ts', + ], + include: ['src/**/*.ts'], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: [ + './src/index.ts', + './src/sqlite.ts', + './src/sqlite/default-schema.ts', + './src/pg/default-schema.ts', + './src/schema-cli-main.ts', + ], + srcDir: './src', + cjs: false, + }), +) diff --git a/packages/ai-persistence-prisma/.gitignore b/packages/ai-persistence-prisma/.gitignore new file mode 100644 index 000000000..e1c30d225 --- /dev/null +++ b/packages/ai-persistence-prisma/.gitignore @@ -0,0 +1,3 @@ +# Scratch sqlite databases used to generate migrations / run tests. +prisma/*.db +prisma/*.db-journal diff --git a/packages/ai-persistence-prisma/bin/tanstack-ai-prisma-models.mjs b/packages/ai-persistence-prisma/bin/tanstack-ai-prisma-models.mjs new file mode 100755 index 000000000..0a4de6588 --- /dev/null +++ b/packages/ai-persistence-prisma/bin/tanstack-ai-prisma-models.mjs @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import '../dist/esm/cli.js' diff --git a/packages/ai-persistence-prisma/package.json b/packages/ai-persistence-prisma/package.json new file mode 100644 index 000000000..46e873719 --- /dev/null +++ b/packages/ai-persistence-prisma/package.json @@ -0,0 +1,91 @@ +{ + "name": "@tanstack/ai-persistence-prisma", + "version": "0.0.0", + "description": "Prisma persistence for TanStack AI with a provider-neutral models fragment and bring-your-own migrated PrismaClient.", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-persistence-prisma" + }, + "keywords": [ + "ai", + "tanstack", + "persistence", + "prisma", + "database" + ], + "type": "module", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + } + }, + "bin": { + "tanstack-ai-prisma-models": "./bin/tanstack-ai-prisma-models.mjs" + }, + "files": [ + "bin", + "dist", + "src", + "prisma/tanstack-ai.prisma" + ], + "scripts": { + "build": "vite build", + "clean": "premove ./build ./dist", + "db:generate": "prisma generate --schema ./prisma", + "lint:fix": "oxlint src --type-aware --fix", + "test:build": "publint --strict", + "test:lib": "vitest", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc", + "test:oxlint": "oxlint src --type-aware" + }, + "nx": { + "targets": { + "db:generate": { + "cache": false + }, + "build": { + "dependsOn": [ + "db:generate", + "^build" + ] + }, + "test:lib": { + "dependsOn": [ + "db:generate", + "^build" + ] + }, + "test:types": { + "dependsOn": [ + "db:generate", + "^build" + ] + }, + "test:eslint": { + "dependsOn": [ + "db:generate", + "^build" + ] + } + } + }, + "peerDependencies": { + "@prisma/client": ">=6.7.0", + "@tanstack/ai": "workspace:*", + "@tanstack/ai-persistence": "workspace:*" + }, + "devDependencies": { + "@prisma/client": "^6.19.3", + "@tanstack/ai": "workspace:*", + "@tanstack/ai-persistence": "workspace:*", + "@vitest/coverage-v8": "4.0.14", + "prisma": "^6.19.3" + } +} diff --git a/packages/ai-persistence-prisma/prisma/schema.prisma b/packages/ai-persistence-prisma/prisma/schema.prisma new file mode 100644 index 000000000..3506e60fb --- /dev/null +++ b/packages/ai-persistence-prisma/prisma/schema.prisma @@ -0,0 +1,10 @@ +// Internal test-client configuration. The models live in the provider-neutral +// sibling fragment that is shipped to package consumers. +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} diff --git a/packages/ai-persistence-prisma/prisma/tanstack-ai.prisma b/packages/ai-persistence-prisma/prisma/tanstack-ai.prisma new file mode 100644 index 000000000..aa716415b --- /dev/null +++ b/packages/ai-persistence-prisma/prisma/tanstack-ai.prisma @@ -0,0 +1,50 @@ +// Provider-neutral TanStack AI persistence models. +// +// Copy this file into a Prisma multi-file schema directory, then create and +// apply migrations using the application's selected provider and normal Prisma +// workflow. This fragment intentionally contains no datasource or generator. + +/// Thread message history (`MessageStore`). +model Message { + threadId String @id @map("thread_id") + messagesJson String @map("messages_json") + + @@map("messages") +} + +/// Run lifecycle records (`RunStore`). +model Run { + runId String @id @map("run_id") + threadId String @map("thread_id") + status String + startedAt BigInt @map("started_at") + finishedAt BigInt? @map("finished_at") + error String? + usageJson String? @map("usage_json") + + @@map("runs") +} + +/// Interrupt / approval records (`InterruptStore`). +model Interrupt { + interruptId String @id @map("interrupt_id") + runId String @map("run_id") + threadId String @map("thread_id") + status String + requestedAt BigInt @map("requested_at") + resolvedAt BigInt? @map("resolved_at") + payloadJson String @map("payload_json") + responseJson String? @map("response_json") + + @@map("interrupts") +} + +/// Scoped key/value metadata (`MetadataStore`). +model Metadata { + scope String + key String + valueJson String @map("value_json") + + @@id([scope, key]) + @@map("metadata") +} diff --git a/packages/ai-persistence-prisma/src/assets.d.ts b/packages/ai-persistence-prisma/src/assets.d.ts new file mode 100644 index 000000000..33c9a3e00 --- /dev/null +++ b/packages/ai-persistence-prisma/src/assets.d.ts @@ -0,0 +1,4 @@ +declare module '*.prisma?raw' { + const contents: string + export default contents +} diff --git a/packages/ai-persistence-prisma/src/assets/tanstack-ai.prisma b/packages/ai-persistence-prisma/src/assets/tanstack-ai.prisma new file mode 100644 index 000000000..aa716415b --- /dev/null +++ b/packages/ai-persistence-prisma/src/assets/tanstack-ai.prisma @@ -0,0 +1,50 @@ +// Provider-neutral TanStack AI persistence models. +// +// Copy this file into a Prisma multi-file schema directory, then create and +// apply migrations using the application's selected provider and normal Prisma +// workflow. This fragment intentionally contains no datasource or generator. + +/// Thread message history (`MessageStore`). +model Message { + threadId String @id @map("thread_id") + messagesJson String @map("messages_json") + + @@map("messages") +} + +/// Run lifecycle records (`RunStore`). +model Run { + runId String @id @map("run_id") + threadId String @map("thread_id") + status String + startedAt BigInt @map("started_at") + finishedAt BigInt? @map("finished_at") + error String? + usageJson String? @map("usage_json") + + @@map("runs") +} + +/// Interrupt / approval records (`InterruptStore`). +model Interrupt { + interruptId String @id @map("interrupt_id") + runId String @map("run_id") + threadId String @map("thread_id") + status String + requestedAt BigInt @map("requested_at") + resolvedAt BigInt? @map("resolved_at") + payloadJson String @map("payload_json") + responseJson String? @map("response_json") + + @@map("interrupts") +} + +/// Scoped key/value metadata (`MetadataStore`). +model Metadata { + scope String + key String + valueJson String @map("value_json") + + @@id([scope, key]) + @@map("metadata") +} diff --git a/packages/ai-persistence-prisma/src/cli.ts b/packages/ai-persistence-prisma/src/cli.ts new file mode 100644 index 000000000..21273f6e3 --- /dev/null +++ b/packages/ai-persistence-prisma/src/cli.ts @@ -0,0 +1,10 @@ +#!/usr/bin/env node +import { runPrismaModelsCli } from './models-cli' + +try { + await runPrismaModelsCli(process.argv.slice(2)) +} catch (error) { + const message = error instanceof Error ? error.message : String(error) + process.stderr.write(`${message}\n`) + process.exitCode = 1 +} diff --git a/packages/ai-persistence-prisma/src/index.ts b/packages/ai-persistence-prisma/src/index.ts new file mode 100644 index 000000000..65ef10c3a --- /dev/null +++ b/packages/ai-persistence-prisma/src/index.ts @@ -0,0 +1,73 @@ +/** + * Prisma-backed state persistence for TanStack AI. + * + * Add the provider-neutral {@link prismaModels} fragment to a Prisma multi-file + * schema, run Prisma's normal migration workflow for your selected provider, + * then pass the generated client to {@link prismaPersistence}. + */ +import { resolveDelegates } from './model-contract' +import { + createInterruptStore, + createMessageStore, + createMetadataStore, + createRunStore, +} from './stores' +import type { ChatPersistence } from '@tanstack/ai-persistence' +import type { PrismaModelMap } from './model-contract' + +export { prismaModels, prismaModelsFilename } from './models' +export { PrismaModelError } from './model-contract' +export type { PrismaModelMap } from './model-contract' + +/** + * Structural stand-in for a generated Prisma client. + * + * We deliberately do **not** import `PrismaClient` from `@prisma/client`: the + * stores only ever read model delegates off the client by name at runtime + * (see `resolveDelegates`), and the delegate query API (`findUnique`, `upsert`, + * `findMany`, `updateMany`, `deleteMany`) is identical across Prisma majors. + * Typing the parameter structurally keeps this package compatible with both the + * v6 `prisma-client-js` generator (client under `@prisma/client`) and the v7 + * `prisma-client` generator (client emitted to a custom `output` path and not + * exported from `@prisma/client`), so v7 users pass their generated client + * without a type mismatch. + */ +export type PrismaClientLike = object + +export interface PrismaPersistenceOptions { + /** + * Rename the TanStack AI models in your copy of the fragment — for example + * to avoid a collision with an existing `Message` or `Run` model — and map + * each store to the renamed client delegate here. Values are the camelCase + * client accessor names: `model ChatMessage` → `{ messages: 'chatMessage' }`. + * Keep the field names and types from the fragment; database table and + * column names are already yours via `@@map` / `@map`. + */ + models?: PrismaModelMap +} + +/** + * Wire TanStack AI persistence stores over a migrated Prisma client. + * + * State stores only — locks are a separate concern. For multi-instance + * coordination use `withLocks` with a distributed `LockStore` (for example + * `createDurableObjectLockStore` from `@tanstack/ai-persistence-cloudflare`). + * Do not invent an in-process lock and pretend it coordinates across instances. + */ +/** + * Returns {@link ChatPersistence} (messages + runs + interrupts + metadata). + */ +export function prismaPersistence( + prisma: PrismaClientLike, + options?: PrismaPersistenceOptions, +): ChatPersistence { + const delegates = resolveDelegates(prisma, options?.models) + return { + stores: { + messages: createMessageStore(delegates), + runs: createRunStore(delegates), + interrupts: createInterruptStore(delegates), + metadata: createMetadataStore(delegates), + }, + } +} diff --git a/packages/ai-persistence-prisma/src/model-contract.ts b/packages/ai-persistence-prisma/src/model-contract.ts new file mode 100644 index 000000000..1096437fa --- /dev/null +++ b/packages/ai-persistence-prisma/src/model-contract.ts @@ -0,0 +1,221 @@ +/** + * Structural contract for the Prisma model delegates the stores operate over. + * + * `prismaPersistence` accepts a model-name map so applications can rename the + * TanStack AI models in their own copy of the fragment — for example to avoid + * collisions with an existing `Message` or `Run` model — and point the runtime + * at the renamed delegates (`prismaPersistence(prisma, { models })`). + * + * What stays fixed is the **client-level field surface**: field names + * (`threadId`, `messagesJson`, …), their types, and the default composite + * unique alias `scope_key` on the metadata model. Database table and column + * names are already yours via `@@map` / `@map` in the fragment, and extra + * app-owned fields are ignored by the stores (keep them optional or defaulted + * so creates succeed). + * + * The delegate interfaces below are the minimal structural slice of the + * generated client the stores call; the generated delegates satisfy them + * (asserted in the package's type tests). + */ + +export interface MessageRow { + threadId: string + messagesJson: string +} + +export interface RunRow { + runId: string + threadId: string + status: string + startedAt: bigint + finishedAt: bigint | null + error: string | null + usageJson: string | null +} + +export interface InterruptRow { + interruptId: string + runId: string + threadId: string + status: string + requestedAt: bigint + resolvedAt: bigint | null + payloadJson: string + responseJson: string | null +} + +export interface MetadataRow { + scope: string + key: string + valueJson: string +} + +export interface MessageDelegate { + findUnique: (args: { + where: { threadId: string } + }) => Promise + upsert: (args: { + where: { threadId: string } + create: { threadId: string; messagesJson: string } + update: { messagesJson: string } + }) => Promise +} + +export interface RunDelegate { + findUnique: (args: { where: { runId: string } }) => Promise + findFirst: (args: { + where: { threadId: string; status: string } + orderBy: { startedAt: 'asc' | 'desc' } + }) => Promise + upsert: (args: { + where: { runId: string } + create: { + runId: string + threadId: string + status: string + startedAt: bigint + } + update: Record + }) => Promise + updateMany: (args: { + where: { runId: string } + data: { + status?: string + finishedAt?: bigint + error?: string + usageJson?: string + } + }) => Promise +} + +export interface InterruptDelegate { + findUnique: (args: { + where: { interruptId: string } + }) => Promise + findMany: (args: { + where: { threadId?: string; runId?: string; status?: string } + orderBy: { requestedAt: 'asc' } + }) => Promise> + upsert: (args: { + where: { interruptId: string } + create: { + interruptId: string + runId: string + threadId: string + status: string + requestedAt: bigint + payloadJson: string + responseJson: string | null + } + update: Record + }) => Promise + updateMany: (args: { + where: { interruptId: string } + data: { status: string; resolvedAt: bigint; responseJson?: string | null } + }) => Promise +} + +export interface MetadataDelegate { + findUnique: (args: { + where: { scope_key: { scope: string; key: string } } + }) => Promise + upsert: (args: { + where: { scope_key: { scope: string; key: string } } + create: { scope: string; key: string; valueJson: string } + update: { valueJson: string } + }) => Promise + deleteMany: (args: { + where: { scope: string; key: string } + }) => Promise +} + +/** The delegate set the stores operate over. */ +export interface TanstackAiDelegates { + message: MessageDelegate + run: RunDelegate + interrupt: InterruptDelegate + metadata: MetadataDelegate +} + +/** + * Store key → Prisma client delegate property. Values are the camelCase + * client accessor names (`prisma.`), not the PascalCase model names: + * a `model ChatMessage` is reached as `chatMessage`. + */ +export interface PrismaModelMap { + messages?: string + runs?: string + interrupts?: string + metadata?: string +} + +const defaultModelNames: Required = { + messages: 'message', + runs: 'run', + interrupts: 'interrupt', + metadata: 'metadata', +} + +const storeKeys = Object.keys(defaultModelNames) as Array< + keyof Required +> + +const delegateKeyByStoreKey: { + [K in keyof Required]: keyof TanstackAiDelegates +} = { + messages: 'message', + runs: 'run', + interrupts: 'interrupt', + metadata: 'metadata', +} + +/** A model map pointed `prismaPersistence` at missing or invalid delegates. */ +export class PrismaModelError extends Error { + constructor(problems: ReadonlyArray) { + super( + `Invalid TanStack AI Prisma model mapping:\n${problems + .map((problem) => ` - ${problem}`) + .join('\n')}`, + ) + this.name = 'PrismaModelError' + } +} + +function isDelegate(value: unknown): boolean { + return ( + value !== null && + typeof value === 'object' && + typeof Reflect.get(value, 'findUnique') === 'function' && + typeof Reflect.get(value, 'upsert') === 'function' + ) +} + +/** + * Resolve the four store delegates off the client, applying any model-name + * overrides and verifying each resolved delegate looks like a Prisma model + * delegate. Field shapes are enforced at compile time by the delegate + * interfaces and are not re-checked here. + */ +export function resolveDelegates( + client: object, + models?: PrismaModelMap, +): TanstackAiDelegates { + const problems: Array = [] + const resolved: Partial> = {} + for (const storeKey of storeKeys) { + const modelName = models?.[storeKey] ?? defaultModelNames[storeKey] + const candidate: unknown = Reflect.get(client, modelName) + if (!isDelegate(candidate)) { + problems.push( + `\`${storeKey}\` maps to \`client.${modelName}\`, which is not a Prisma model delegate.`, + ) + continue + } + resolved[delegateKeyByStoreKey[storeKey]] = candidate + } + if (problems.length > 0) throw new PrismaModelError(problems) + // Safe narrowing: presence and delegate shape were verified above; the + // per-method argument/result types are pinned by the delegate interfaces, + // which the generated client's delegates satisfy (asserted in type tests). + return resolved as TanstackAiDelegates +} diff --git a/packages/ai-persistence-prisma/src/models-cli.ts b/packages/ai-persistence-prisma/src/models-cli.ts new file mode 100644 index 000000000..42410dd8e --- /dev/null +++ b/packages/ai-persistence-prisma/src/models-cli.ts @@ -0,0 +1,121 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { prismaModels, prismaModelsFilename } from './models' + +export interface ModelsCliOutput { + writeStdout: (value: string) => void +} + +export class ModelsCliError extends Error { + constructor(message: string) { + super(message) + this.name = 'ModelsCliError' + } +} + +const usage = `Usage: tanstack-ai-prisma-models (--out | --stdout) [--force] + +Options: + --out Copy the models-only Prisma schema fragment. + --stdout Print the models-only Prisma schema fragment. + --force Replace a divergent fragment when copying. + --help Show this help. +` + +interface ParsedArguments { + force: boolean + help: boolean + outDirectory?: string + stdout: boolean +} + +function parseArguments(args: ReadonlyArray): ParsedArguments { + let force = false + let help = false + let outDirectory: string | undefined + let stdout = false + + for (let index = 0; index < args.length; index++) { + const argument = args[index] + if (argument === '--force') { + force = true + } else if (argument === '--help' || argument === '-h') { + help = true + } else if (argument === '--stdout') { + stdout = true + } else if (argument === '--out') { + const value = args[index + 1] + if (!value || value.startsWith('-')) { + throw new ModelsCliError('--out requires a directory.') + } + if (outDirectory !== undefined) { + throw new ModelsCliError('--out may only be provided once.') + } + outDirectory = value + index++ + } else { + throw new ModelsCliError(`Unknown argument: ${argument ?? ''}`) + } + } + + return { force, help, outDirectory, stdout } +} + +function isMissingFileError(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +async function readExistingFile(path: string): Promise { + try { + return await readFile(path, 'utf8') + } catch (error) { + if (isMissingFileError(error)) return undefined + throw error + } +} + +/** Execute the models asset CLI. Exported for deterministic CLI tests. */ +export async function runPrismaModelsCli( + args: ReadonlyArray, + output: ModelsCliOutput = { + writeStdout(value) { + process.stdout.write(value) + }, + }, +): Promise { + const parsed = parseArguments(args) + if (parsed.help) { + output.writeStdout(usage) + return + } + + const outputCount = + Number(parsed.stdout) + Number(parsed.outDirectory != null) + if (outputCount !== 1) { + throw new ModelsCliError( + 'Provide exactly one output mode: --out or --stdout.', + ) + } + if (parsed.stdout) { + if (parsed.force) { + throw new ModelsCliError('--force can only be used with --out.') + } + output.writeStdout(`${prismaModels.trimEnd()}\n`) + return + } + + const outDirectory = parsed.outDirectory + if (!outDirectory) { + throw new ModelsCliError('--out requires a directory.') + } + await mkdir(outDirectory, { recursive: true }) + const destination = join(outDirectory, prismaModelsFilename) + const existing = await readExistingFile(destination) + if (existing === prismaModels) return + if (existing !== undefined && !parsed.force) { + throw new ModelsCliError( + `Refusing to overwrite divergent Prisma models file: ${destination}. Re-run with --force to replace it.`, + ) + } + await writeFile(destination, prismaModels, 'utf8') +} diff --git a/packages/ai-persistence-prisma/src/models.ts b/packages/ai-persistence-prisma/src/models.ts new file mode 100644 index 000000000..6e22bb77b --- /dev/null +++ b/packages/ai-persistence-prisma/src/models.ts @@ -0,0 +1,7 @@ +import models from './assets/tanstack-ai.prisma?raw' + +/** Filename used by the Prisma models copy CLI. */ +export const prismaModelsFilename = 'tanstack-ai.prisma' + +/** Provider-neutral, models-only Prisma schema fragment. */ +export const prismaModels: string = models diff --git a/packages/ai-persistence-prisma/src/stores.ts b/packages/ai-persistence-prisma/src/stores.ts new file mode 100644 index 000000000..15b864a8d --- /dev/null +++ b/packages/ai-persistence-prisma/src/stores.ts @@ -0,0 +1,244 @@ +/** + * AIPersistence store implementations over a Prisma `PrismaClient`. + * + * Each method mirrors the reference in-memory backend + * (`@tanstack/ai-persistence`'s `memory.ts`) and the sibling Drizzle backend + * (`@tanstack/ai-persistence-drizzle`'s `stores.ts`), including the + * insert-if-absent `InterruptStore.create` and `RunStore.createOrResume` + * semantics (`upsert` with an empty `update`). JSON-valued columns use + * provider-neutral Prisma `String` fields, so they are serialized with + * `JSON.stringify`/`JSON.parse` here. + */ +import type { + InterruptRow, + RunRow, + TanstackAiDelegates, +} from './model-contract' +import type { ModelMessage } from '@tanstack/ai' +import type { + InterruptRecord, + InterruptStore, + MessageStore, + MetadataStore, + RunRecord, + RunStatus, + RunStore, +} from '@tanstack/ai-persistence' + +export function createMessageStore({ + message, +}: TanstackAiDelegates): MessageStore { + return { + async loadThread(threadId) { + const row = await message.findUnique({ where: { threadId } }) + if (!row) return [] + return JSON.parse(row.messagesJson) as Array + }, + async saveThread(threadId, msgs: Array) { + const messagesJson = JSON.stringify(msgs) + await message.upsert({ + where: { threadId }, + create: { threadId, messagesJson }, + update: { messagesJson }, + }) + }, + } +} + +function mapRun(row: RunRow): RunRecord { + return { + runId: row.runId, + threadId: row.threadId, + status: row.status as RunStatus, + startedAt: Number(row.startedAt), + ...(row.finishedAt != null ? { finishedAt: Number(row.finishedAt) } : {}), + ...(row.error != null ? { error: row.error } : {}), + ...(row.usageJson != null + ? { usage: JSON.parse(row.usageJson) as RunRecord['usage'] } + : {}), + } +} + +export function createRunStore({ run }: TanstackAiDelegates): RunStore { + const store: RunStore = { + async createOrResume(input) { + const existing = await store.get(input.runId) + if (existing) return existing + const record: RunRecord = { + runId: input.runId, + threadId: input.threadId, + status: input.status ?? 'running', + startedAt: input.startedAt, + } + // Upsert with an empty update = insert-if-absent (never overwrites an + // existing run), matching the reference createOrResume semantics. + await run.upsert({ + where: { runId: record.runId }, + create: { + runId: record.runId, + threadId: record.threadId, + status: record.status, + startedAt: BigInt(record.startedAt), + }, + update: {}, + }) + return (await store.get(input.runId)) ?? record + }, + async update(runId, patch) { + const data: { + status?: RunStatus + finishedAt?: bigint + error?: string + usageJson?: string + } = {} + if (patch.status !== undefined) data.status = patch.status + if (patch.finishedAt !== undefined) + data.finishedAt = BigInt(patch.finishedAt) + if (patch.error !== undefined) data.error = patch.error + if (patch.usage !== undefined) + data.usageJson = JSON.stringify(patch.usage) + if (Object.keys(data).length === 0) return + // updateMany no-ops (does not throw) when the run does not exist. + await run.updateMany({ where: { runId }, data }) + }, + async get(runId) { + const row = await run.findUnique({ where: { runId } }) + return row ? mapRun(row) : null + }, + async findActiveRun(threadId) { + const row = await run.findFirst({ + where: { threadId, status: 'running' }, + orderBy: { startedAt: 'desc' }, + }) + return row ? mapRun(row) : null + }, + } + return store +} + +function mapInterrupt(row: InterruptRow): InterruptRecord { + return { + interruptId: row.interruptId, + runId: row.runId, + threadId: row.threadId, + status: row.status as InterruptRecord['status'], + requestedAt: Number(row.requestedAt), + ...(row.resolvedAt != null ? { resolvedAt: Number(row.resolvedAt) } : {}), + payload: JSON.parse(row.payloadJson) as Record, + ...(row.responseJson != null + ? { response: JSON.parse(row.responseJson) as unknown } + : {}), + } +} + +export function createInterruptStore({ + interrupt, +}: TanstackAiDelegates): InterruptStore { + return { + async create(record) { + await interrupt.upsert({ + where: { interruptId: record.interruptId }, + create: { + interruptId: record.interruptId, + runId: record.runId, + threadId: record.threadId, + status: 'pending', + requestedAt: BigInt(record.requestedAt), + payloadJson: JSON.stringify(record.payload), + responseJson: + record.response == null ? null : JSON.stringify(record.response), + }, + update: {}, + }) + }, + async resolve(interruptId, response) { + await interrupt.updateMany({ + where: { interruptId }, + data: { + status: 'resolved', + resolvedAt: BigInt(Date.now()), + responseJson: response == null ? null : JSON.stringify(response), + }, + }) + }, + async cancel(interruptId) { + await interrupt.updateMany({ + where: { interruptId }, + data: { status: 'cancelled', resolvedAt: BigInt(Date.now()) }, + }) + }, + async get(interruptId) { + const row = await interrupt.findUnique({ where: { interruptId } }) + return row ? mapInterrupt(row) : null + }, + async list(threadId) { + const rows = await interrupt.findMany({ + where: { threadId }, + orderBy: { requestedAt: 'asc' }, + }) + return rows.map(mapInterrupt) + }, + async listPending(threadId) { + const rows = await interrupt.findMany({ + where: { threadId, status: 'pending' }, + orderBy: { requestedAt: 'asc' }, + }) + return rows.map(mapInterrupt) + }, + async listByRun(runId) { + const rows = await interrupt.findMany({ + where: { runId }, + orderBy: { requestedAt: 'asc' }, + }) + return rows.map(mapInterrupt) + }, + async listPendingByRun(runId) { + const rows = await interrupt.findMany({ + where: { runId, status: 'pending' }, + orderBy: { requestedAt: 'asc' }, + }) + return rows.map(mapInterrupt) + }, + } +} + +function assertStorableMetadata(value: unknown): void { + if (value == null) { + throw new TypeError( + `TanStack AI metadata values must be defined, non-null JSON; received ${ + value === undefined ? '`undefined`' : '`null`' + }. Use \`delete(scope, key)\` to clear a value.`, + ) + } +} + +export function createMetadataStore({ + metadata, +}: TanstackAiDelegates): MetadataStore { + return { + async get(scope, key) { + const row = await metadata.findUnique({ + where: { scope_key: { scope, key } }, + }) + return row ? (JSON.parse(row.valueJson) as unknown) : null + }, + async set(scope, key, value) { + // SQL backends store JSON text in a NOT NULL column and cannot persist a + // nullish value: `JSON.stringify(undefined)` is `undefined` (no string), + // and the sibling Drizzle backend binds a JS `null` as SQL NULL — both + // violate NOT NULL. Reject nullish with a clear error (consistent across + // the SQL backends) instead of a cryptic driver failure. Unlike the + // in-memory reference, which round-trips nullish; use `delete` to clear. + assertStorableMetadata(value) + const valueJson = JSON.stringify(value) + await metadata.upsert({ + where: { scope_key: { scope, key } }, + create: { scope, key, valueJson }, + update: { valueJson }, + }) + }, + async delete(scope, key) { + await metadata.deleteMany({ where: { scope, key } }) + }, + } +} diff --git a/packages/ai-persistence-prisma/tests/api-types.test-d.ts b/packages/ai-persistence-prisma/tests/api-types.test-d.ts new file mode 100644 index 000000000..09bf0d649 --- /dev/null +++ b/packages/ai-persistence-prisma/tests/api-types.test-d.ts @@ -0,0 +1,36 @@ +import { expectTypeOf } from 'vitest' +import { PrismaClient } from '@prisma/client' +import type { + ChatPersistence, + MessageStore, + RunStore, +} from '@tanstack/ai-persistence' +import { prismaPersistence } from '../src/index' +import type { + InterruptDelegate, + MessageDelegate, + MetadataDelegate, + RunDelegate, +} from '../src/model-contract' + +declare const prisma: PrismaClient +const persistence = prismaPersistence(prisma) + +// The generated client's delegates must satisfy the structural delegate +// contract the stores are written against — this is what makes renamed +// delegates from a user-generated client interchangeable with canonical ones. +expectTypeOf(prisma.message).toExtend() +expectTypeOf(prisma.run).toExtend() +expectTypeOf(prisma.interrupt).toExtend() +expectTypeOf(prisma.metadata).toExtend() + +const mapped = prismaPersistence(prisma, { + models: { messages: 'chatMessage' }, +}) +expectTypeOf(mapped).toEqualTypeOf() + +expectTypeOf(persistence).toEqualTypeOf() +expectTypeOf(persistence.stores.messages).toEqualTypeOf() +expectTypeOf(persistence.stores.runs).toEqualTypeOf() +// State bag only — locks are provided separately via withLocks. +expectTypeOf(persistence.stores).not.toHaveProperty('locks') diff --git a/packages/ai-persistence-prisma/tests/model-mapping.test.ts b/packages/ai-persistence-prisma/tests/model-mapping.test.ts new file mode 100644 index 000000000..0411125ec --- /dev/null +++ b/packages/ai-persistence-prisma/tests/model-mapping.test.ts @@ -0,0 +1,133 @@ +import { rm } from 'node:fs/promises' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { afterAll, describe, expect, it } from 'vitest' +import { PrismaClient } from '@prisma/client' +import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' +import { PrismaModelError, prismaPersistence } from '../src/index' + +const clients: Array = [] +const temporaryDirectories: Array = [] + +const sqliteTestSchema = ` + CREATE TABLE messages ( + thread_id TEXT NOT NULL PRIMARY KEY, + messages_json TEXT NOT NULL + ); + CREATE TABLE runs ( + run_id TEXT NOT NULL PRIMARY KEY, + thread_id TEXT NOT NULL, + status TEXT NOT NULL, + started_at BIGINT NOT NULL, + finished_at BIGINT, + error TEXT, + usage_json TEXT + ); + CREATE TABLE interrupts ( + interrupt_id TEXT NOT NULL PRIMARY KEY, + run_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + status TEXT NOT NULL, + requested_at BIGINT NOT NULL, + resolved_at BIGINT, + payload_json TEXT NOT NULL, + response_json TEXT + ); + CREATE TABLE metadata ( + scope TEXT NOT NULL, + key TEXT NOT NULL, + value_json TEXT NOT NULL, + PRIMARY KEY (scope, key) + ); +` + +async function makeTestClient(): Promise { + const dir = mkdtempSync(join(tmpdir(), 'tanstack-ai-prisma-mapping-')) + temporaryDirectories.push(dir) + const dbPath = join(dir, 'state.db').replace(/\\/g, '/') + const database = new DatabaseSync(dbPath) + try { + database.exec(sqliteTestSchema) + } finally { + database.close() + } + const prisma = new PrismaClient({ + datasources: { db: { url: `file:${dbPath}` } }, + }) + clients.push(prisma) + return prisma +} + +/** + * A client whose TanStack AI model delegates live under renamed accessors, as + * generated from a fragment whose models were renamed to avoid collisions + * (e.g. `model ChatMessage`). Aliasing the canonical delegates is faithful: + * a delegate is bound to its model, not to the property it hangs off. + */ +async function makeRenamedClient(): Promise { + const prisma = await makeTestClient() + const renamed = { + chatMessage: prisma.message, + chatRun: prisma.run, + chatInterrupt: prisma.interrupt, + chatMetadata: prisma.metadata, + } + return renamed as unknown as PrismaClient +} + +const renamedModels = { + messages: 'chatMessage', + runs: 'chatRun', + interrupts: 'chatInterrupt', + metadata: 'chatMetadata', +} + +afterAll(async () => { + await Promise.all(clients.map((client) => client.$disconnect())) + await Promise.all( + temporaryDirectories.map((directory) => + rm(directory, { recursive: true, force: true }), + ), + ) +}) + +// The full store contract must hold when every model is reached through a +// renamed delegate. +runPersistenceConformance('prisma (renamed models)', async () => + prismaPersistence(await makeRenamedClient(), { models: renamedModels }), +) + +describe('prismaPersistence model mapping', () => { + it('rejects a mapping that points at missing delegates', async () => { + const prisma = await makeTestClient() + expect(() => + prismaPersistence(prisma, { models: { messages: 'chatMessage' } }), + ).toThrow(PrismaModelError) + expect(() => + prismaPersistence(prisma, { models: { messages: 'chatMessage' } }), + ).toThrow(/`messages` maps to `client\.chatMessage`/) + }) + + it('lists every unresolved model in one error', async () => { + const prisma = await makeRenamedClient() + // Default names resolve nothing on a fully renamed client. + expect(() => prismaPersistence(prisma)).toThrow( + /messages[\s\S]*runs[\s\S]*interrupts[\s\S]*metadata/, + ) + }) + + it('supports partial maps over an otherwise canonical client', async () => { + const prisma = await makeTestClient() + const persistence = prismaPersistence(prisma, { + models: { messages: 'message' }, + }) + await persistence.stores.messages.saveThread('thread-1', [ + { role: 'user', content: 'hello' }, + ]) + expect( + await persistence.stores.messages.loadThread('thread-1'), + ).toHaveLength(1) + }) +}) diff --git a/packages/ai-persistence-prisma/tests/models-cli.test.ts b/packages/ai-persistence-prisma/tests/models-cli.test.ts new file mode 100644 index 000000000..36a997568 --- /dev/null +++ b/packages/ai-persistence-prisma/tests/models-cli.test.ts @@ -0,0 +1,59 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { prismaModels, prismaModelsFilename } from '../src/index' +import { runPrismaModelsCli } from '../src/models-cli' + +const temporaryDirectories: Array = [] + +async function createTemporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'tanstack-ai-prisma-cli-')) + temporaryDirectories.push(directory) + return directory +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ) +}) + +describe('Prisma models CLI', () => { + it('prints the models fragment to stdout', async () => { + let output = '' + await runPrismaModelsCli(['--stdout'], { + writeStdout(value) { + output += value + }, + }) + expect(output).toBe(`${prismaModels.trimEnd()}\n`) + }) + + it('copies the fragment without overwriting divergent files by default', async () => { + const directory = await createTemporaryDirectory() + await runPrismaModelsCli(['--out', directory]) + + const destination = join(directory, prismaModelsFilename) + expect(await readFile(destination, 'utf8')).toBe(prismaModels) + + await writeFile(destination, 'user-owned contents', 'utf8') + await expect(runPrismaModelsCli(['--out', directory])).rejects.toThrow( + /refusing to overwrite/i, + ) + expect(await readFile(destination, 'utf8')).toBe('user-owned contents') + + await runPrismaModelsCli(['--out', directory, '--force']) + expect(await readFile(destination, 'utf8')).toBe(prismaModels) + }) + + it('fails on ambiguous or incomplete output options', async () => { + const directory = await createTemporaryDirectory() + await expect(runPrismaModelsCli([])).rejects.toThrow(/--out.*--stdout/i) + await expect( + runPrismaModelsCli(['--stdout', '--out', directory]), + ).rejects.toThrow(/exactly one/i) + }) +}) diff --git a/packages/ai-persistence-prisma/tests/models.test.ts b/packages/ai-persistence-prisma/tests/models.test.ts new file mode 100644 index 000000000..5711faaeb --- /dev/null +++ b/packages/ai-persistence-prisma/tests/models.test.ts @@ -0,0 +1,66 @@ +import { execFileSync } from 'node:child_process' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { prismaModels, prismaModelsFilename } from '../src/index' + +const temporaryDirectories: Array = [] +const prismaCli = fileURLToPath( + new URL('../node_modules/prisma/build/index.js', import.meta.url), +) + +async function createTemporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'tanstack-ai-prisma-models-')) + temporaryDirectories.push(directory) + return directory +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ) +}) + +describe('Prisma models asset', () => { + it('is a models-only fragment with no provider or client generator', () => { + expect(prismaModelsFilename).toBe('tanstack-ai.prisma') + expect(prismaModels).toContain('model Message') + expect(prismaModels).toContain('model Metadata') + expect(prismaModels).not.toMatch(/\bgenerator\s+\w+\s*{/) + expect(prismaModels).not.toMatch(/\bdatasource\s+\w+\s*{/) + }) + + it('keeps the shipped models file equal to the embedded asset', async () => { + const shipped = await readFile( + fileURLToPath(new URL('../prisma/tanstack-ai.prisma', import.meta.url)), + 'utf8', + ) + expect(shipped).toBe(prismaModels) + }) + + it.each([ + ['sqlite', 'file:./state.db'], + ['postgresql', 'postgresql://user:password@localhost:5432/database'], + ['mysql', 'mysql://user:password@localhost:3306/database'], + ])('validates when composed into a %s schema', async (provider, url) => { + const directory = await createTemporaryDirectory() + const schemaPath = join(directory, 'schema.prisma') + await writeFile( + schemaPath, + `datasource db {\n provider = "${provider}"\n url = "${url}"\n}\n\n${prismaModels}`, + 'utf8', + ) + + expect(() => + execFileSync( + process.execPath, + [prismaCli, 'validate', '--schema', schemaPath], + { encoding: 'utf8', stdio: 'pipe' }, + ), + ).not.toThrow() + }) +}) diff --git a/packages/ai-persistence-prisma/tests/package-contract.test.ts b/packages/ai-persistence-prisma/tests/package-contract.test.ts new file mode 100644 index 000000000..a823747df --- /dev/null +++ b/packages/ai-persistence-prisma/tests/package-contract.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import packageJson from '../package.json' + +describe('Prisma package contract', () => { + it('publishes the adapter, models CLI, and provider-neutral models asset', () => { + expect(packageJson.exports).toEqual({ + '.': { + types: './dist/esm/index.d.ts', + import: './dist/esm/index.js', + }, + }) + expect(packageJson.bin).toEqual({ + 'tanstack-ai-prisma-models': './bin/tanstack-ai-prisma-models.mjs', + }) + expect(packageJson.files).toEqual( + expect.arrayContaining([ + 'bin', + 'dist', + 'src', + 'prisma/tanstack-ai.prisma', + ]), + ) + }) + + it('requires a Prisma client with generally available multi-file schemas', () => { + expect(packageJson.peerDependencies['@prisma/client']).toBe('>=6.7.0') + }) +}) diff --git a/packages/ai-persistence-prisma/tests/prisma.conformance.test.ts b/packages/ai-persistence-prisma/tests/prisma.conformance.test.ts new file mode 100644 index 000000000..b2accb678 --- /dev/null +++ b/packages/ai-persistence-prisma/tests/prisma.conformance.test.ts @@ -0,0 +1,79 @@ +import { rm } from 'node:fs/promises' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { afterAll } from 'vitest' +import { PrismaClient } from '@prisma/client' +import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' +import { prismaPersistence } from '../src/index' + +const clients: Array = [] +const temporaryDirectories: Array = [] + +const sqliteTestSchema = ` + CREATE TABLE messages ( + thread_id TEXT NOT NULL PRIMARY KEY, + messages_json TEXT NOT NULL + ); + CREATE TABLE runs ( + run_id TEXT NOT NULL PRIMARY KEY, + thread_id TEXT NOT NULL, + status TEXT NOT NULL, + started_at BIGINT NOT NULL, + finished_at BIGINT, + error TEXT, + usage_json TEXT + ); + CREATE TABLE interrupts ( + interrupt_id TEXT NOT NULL PRIMARY KEY, + run_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + status TEXT NOT NULL, + requested_at BIGINT NOT NULL, + resolved_at BIGINT, + payload_json TEXT NOT NULL, + response_json TEXT + ); + CREATE TABLE metadata ( + scope TEXT NOT NULL, + key TEXT NOT NULL, + value_json TEXT NOT NULL, + PRIMARY KEY (scope, key) + ); +` + +function initializeSqliteTestDatabase(path: string): void { + const database = new DatabaseSync(path) + try { + database.exec(sqliteTestSchema) + } finally { + database.close() + } +} + +/** Create a PrismaClient over a fresh initialized temporary SQLite database. */ +async function makeTestClient(): Promise { + const dir = mkdtempSync(join(tmpdir(), 'tanstack-ai-prisma-')) + temporaryDirectories.push(dir) + const dbPath = join(dir, 'state.db').replace(/\\/g, '/') + initializeSqliteTestDatabase(dbPath) + const prisma = new PrismaClient({ + datasources: { db: { url: `file:${dbPath}` } }, + }) + clients.push(prisma) + return prisma +} + +afterAll(async () => { + await Promise.all(clients.map((client) => client.$disconnect())) + await Promise.all( + temporaryDirectories.map((directory) => + rm(directory, { recursive: true, force: true }), + ), + ) +}) + +runPersistenceConformance('prisma', async () => + prismaPersistence(await makeTestClient()), +) diff --git a/packages/ai-persistence-prisma/tests/store-behavior.test.ts b/packages/ai-persistence-prisma/tests/store-behavior.test.ts new file mode 100644 index 000000000..f1b862b74 --- /dev/null +++ b/packages/ai-persistence-prisma/tests/store-behavior.test.ts @@ -0,0 +1,72 @@ +import { rm } from 'node:fs/promises' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { afterAll, describe, expect, it } from 'vitest' +import { PrismaClient } from '@prisma/client' +import { prismaPersistence } from '../src/index' + +const clients: Array = [] +const directories: Array = [] + +// Only the tables these tests query need to exist; the generated client carries +// all delegates regardless of which tables the database has. +const schema = ` + CREATE TABLE metadata ( + scope TEXT NOT NULL, + key TEXT NOT NULL, + value_json TEXT NOT NULL, + PRIMARY KEY (scope, key) + ); +` + +function makePersistence(): ReturnType { + const dir = mkdtempSync(join(tmpdir(), 'tanstack-ai-prisma-behavior-')) + directories.push(dir) + const dbPath = join(dir, 'state.db').replace(/\\/g, '/') + const database = new DatabaseSync(dbPath) + try { + database.exec(schema) + } finally { + database.close() + } + const prisma = new PrismaClient({ + datasources: { db: { url: `file:${dbPath}` } }, + }) + clients.push(prisma) + return prismaPersistence(prisma) +} + +afterAll(async () => { + await Promise.all(clients.map((client) => client.$disconnect())) + await Promise.all( + directories.map((directory) => + rm(directory, { recursive: true, force: true }), + ), + ) +}) + +describe('prisma metadata nullish values', () => { + it('rejects nullish values with a clear error instead of a NOT NULL crash', async () => { + const { stores } = makePersistence() + + // A NOT NULL column cannot store nullish; surface a clear, actionable error + // (consistent with the Drizzle backend) rather than a driver crash. + await expect(stores.metadata.set('scope', 'u', undefined)).rejects.toThrow( + /metadata values must be defined/, + ) + await expect(stores.metadata.set('scope', 'n', null)).rejects.toThrow( + /metadata values must be defined/, + ) + expect(await stores.metadata.get('scope', 'u')).toBeNull() + + // Falsy-but-defined values are stored and round-trip. + await stores.metadata.set('scope', 'zero', 0) + expect(await stores.metadata.get('scope', 'zero')).toBe(0) + await stores.metadata.set('scope', 'empty', '') + expect(await stores.metadata.get('scope', 'empty')).toBe('') + await stores.metadata.set('scope', 'obj', { a: 1 }) + expect(await stores.metadata.get('scope', 'obj')).toEqual({ a: 1 }) + }) +}) diff --git a/packages/ai-persistence-prisma/tsconfig.json b/packages/ai-persistence-prisma/tsconfig.json new file mode 100644 index 000000000..c38689f4e --- /dev/null +++ b/packages/ai-persistence-prisma/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ai-persistence-prisma/vite.config.ts b/packages/ai-persistence-prisma/vite.config.ts new file mode 100644 index 000000000..f56314fed --- /dev/null +++ b/packages/ai-persistence-prisma/vite.config.ts @@ -0,0 +1,37 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + '**/types.ts', + ], + include: ['src/**/*.ts'], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts', './src/cli.ts'], + srcDir: './src', + cjs: false, + }), +) diff --git a/packages/ai-persistence/package.json b/packages/ai-persistence/package.json new file mode 100644 index 000000000..229ba692d --- /dev/null +++ b/packages/ai-persistence/package.json @@ -0,0 +1,63 @@ +{ + "name": "@tanstack/ai-persistence", + "version": "0.0.0", + "description": "Composable state persistence for TanStack AI messages, runs, interrupts, metadata, and locks.", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-persistence" + }, + "keywords": [ + "ai", + "ai-sdk", + "typescript", + "tanstack", + "persistence", + "durable", + "resume", + "chat-history" + ], + "type": "module", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + }, + "./testkit": { + "types": "./dist/esm/testkit/conformance.d.ts", + "import": "./dist/esm/testkit/conformance.js" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "vite build", + "clean": "premove ./build ./dist", + "lint:fix": "oxlint src --type-aware --fix", + "test:build": "publint --strict", + "test:lib": "vitest", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc", + "test:oxlint": "oxlint src --type-aware" + }, + "peerDependencies": { + "@tanstack/ai": "workspace:*", + "vitest": "^4.1.10" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + }, + "devDependencies": { + "@tanstack/ai": "workspace:*", + "@vitest/coverage-v8": "4.0.14", + "vitest": "^4.1.10" + } +} diff --git a/packages/ai-persistence/src/capabilities.ts b/packages/ai-persistence/src/capabilities.ts new file mode 100644 index 000000000..0236a8d10 --- /dev/null +++ b/packages/ai-persistence/src/capabilities.ts @@ -0,0 +1,22 @@ +/** + * Persistence capability tokens. + * + * `withPersistence` PROVIDES persistence/interrupts so later middleware can + * read durable state. Locks are a separate concern: `withLocks` PROVIDES + * `LocksCapability` (defined in `./locks`). + */ +import { createCapability } from '@tanstack/ai' +import type { AIPersistence, InterruptStore } from './types' + +export const PersistenceCapability = + createCapability()('persistence') + +export const InterruptsCapability = createCapability()( + 'persistence.interrupts', +) + +export const [getPersistence, providePersistence] = PersistenceCapability +export const [getInterrupts, provideInterrupts] = InterruptsCapability + +// Locks token lives in ./locks; re-export for a single import surface. +export { LocksCapability, getLocks, provideLocks } from './locks' diff --git a/packages/ai-persistence/src/index.ts b/packages/ai-persistence/src/index.ts new file mode 100644 index 000000000..e06abd68f --- /dev/null +++ b/packages/ai-persistence/src/index.ts @@ -0,0 +1,62 @@ +// Store contracts + named chat shapes +export { composePersistence, defineAIPersistence } 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 + separate locks) +export { + withPersistence, + withGenerationPersistence, + withLocks, +} 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' + +// Capabilities +export { + PersistenceCapability, + InterruptsCapability, + getPersistence, + providePersistence, + getInterrupts, + provideInterrupts, + LocksCapability, + getLocks, + provideLocks, +} from './capabilities' + +// Lock primitive (separate from state stores; provide via withLocks) +export { InMemoryLockStore } from './locks' +export type { LockStore } from './locks' diff --git a/packages/ai-persistence/src/interrupts.ts b/packages/ai-persistence/src/interrupts.ts new file mode 100644 index 000000000..f8cc1d329 --- /dev/null +++ b/packages/ai-persistence/src/interrupts.ts @@ -0,0 +1,24 @@ +import type { InterruptRecord, InterruptStore } from './types' + +export interface InterruptController { + resolve: (interruptId: string, response?: unknown) => Promise + cancel: (interruptId: string) => Promise + request: ( + record: Omit, + ) => Promise + listPending: (threadId: string) => Promise> + listPendingByRun: (runId: string) => Promise> +} + +export function createInterruptController(opts: { + store: InterruptStore +}): InterruptController { + const { store } = opts + return { + resolve: (interruptId, response) => store.resolve(interruptId, response), + cancel: (interruptId) => store.cancel(interruptId), + request: (record) => store.create(record), + listPending: (threadId) => store.listPending(threadId), + listPendingByRun: (runId) => store.listPendingByRun(runId), + } +} diff --git a/packages/ai-persistence/src/locks.ts b/packages/ai-persistence/src/locks.ts new file mode 100644 index 000000000..dfac8ff85 --- /dev/null +++ b/packages/ai-persistence/src/locks.ts @@ -0,0 +1,71 @@ +/** + * Distributed-mutex primitive for multi-instance coordination. + * + * Locks are **not** part of {@link AIPersistenceStores}. State persistence + * (messages/runs/interrupts/metadata) and mutual exclusion are separate + * concerns: wire locks with {@link withLocks} (or pass a `LockStore` into + * consumers that need one), not via `composePersistence`. + * + * ponytail: the `'locks'` capability token is defined LOCALLY here. Capability + * identity is by object reference (see `createCapability`), not by name, so this + * token does NOT interoperate with the identically-named `LocksCapability` that + * `@tanstack/ai-sandbox` owns. That is fine for this batch: the only consumer of + * a provided lock store is `withSandbox`, and sandbox persistence is deferred. + * When it lands, share ONE handle between provider and consumer (the neutral + * home is core `@tanstack/ai`); until then this token has no cross-package + * consumer and just keeps the API self-contained. + */ +import { createCapability } from '@tanstack/ai' + +/** + * Mutual exclusion around a critical section keyed by `key`. A Cloudflare + * Durable Object store is the reference multi-instance backend; the in-memory + * default is correct within a single process only. Lease-backed implementations + * abort `signal` as soon as ownership can no longer be guaranteed; the callback + * must stop externally visible mutations when it aborts. + */ +export interface LockStore { + withLock: ( + key: string, + fn: (signal: AbortSignal) => Promise, + ) => Promise +} + +/** The lock capability. Provided by {@link withLocks}. */ +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 (e.g. Cloudflare + * Durable Objects via `@tanstack/ai-persistence-cloudflare`). + */ +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 + } +} diff --git a/packages/ai-persistence/src/memory.ts b/packages/ai-persistence/src/memory.ts new file mode 100644 index 000000000..e3a1d291b --- /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 {@link InMemoryLockStore} + {@link withLocks} + * 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..b7da747a0 --- /dev/null +++ b/packages/ai-persistence/src/middleware.ts @@ -0,0 +1,713 @@ +import { defineChatMiddleware } from '@tanstack/ai' +import { + InterruptsCapability, + LocksCapability, + PersistenceCapability, + provideInterrupts, + provideLocks, + 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 { LockStore } from './locks' +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 {@link withLocks} 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) + }, + }) +} + +// --------------------------------------------------------------------------- +// Locks middleware (coordination — not state persistence) +// --------------------------------------------------------------------------- + +/** + * Provide a {@link LockStore} on the chat middleware capability bus. + * + * Locks are independent of {@link withPersistence}: state backends (Drizzle, + * Prisma, D1, memory) do not ship locks, and multi-instance apps compose a + * distributed lock separately (e.g. Cloudflare Durable Objects). + * + * ```ts + * middleware: [ + * withPersistence(drizzlePersistence(db, opts)), + * withLocks(createDurableObjectLockStore(env.AI_LOCKS)), + * ] + * ``` + */ +export function withLocks(locks: LockStore): ChatMiddleware { + return defineChatMiddleware({ + name: 'locks', + provides: [LocksCapability], + setup(ctx: ChatMiddlewareContext) { + provideLocks(ctx, locks) + }, + }) +} + +// --------------------------------------------------------------------------- +// 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..0ef13d564 --- /dev/null +++ b/packages/ai-persistence/src/testkit/conformance.ts @@ -0,0 +1,431 @@ +/** + * Shared conformance suite for the `AIPersistence` **state** contract. + * + * Every backend (memory, drizzle, prisma, cloudflare D1, …) runs this identical + * suite so that schema drift or an implementation gap fails immediately. It + * exercises every method of every store the persistence exposes and is the + * authoritative compatibility gate for the store interfaces in `../types.ts`. + * + * 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..8923e5d00 --- /dev/null +++ b/packages/ai-persistence/src/types.ts @@ -0,0 +1,528 @@ +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 (memory, drizzle, prisma, cloudflare, …). +// To avoid breaking existing adapters: +// +// - New store methods are added as OPTIONAL (`method?: (...) => ...`). The +// middleware feature-detects them (`store.method?.(...)`) and degrades +// gracefully when a backend has not implemented them yet. +// - Never tighten an existing method's required arguments or widen its +// required return shape in a breaking way. +// +// The shared conformance testkit (`./testkit/conformance.ts`) is the +// authoritative compatibility gate: every invariant documented on the methods +// below is asserted there, and every backend runs the identical suite. If an +// invariant is not encoded in the testkit, adapters cannot discover it — so +// promote new invariants into both the JSDoc here AND the testkit. +// +// TIMESTAMP CONVENTION +// -------------------- +// Store *records* (`RunRecord`, `InterruptRecord`) speak **epoch +// milliseconds** (`number`), the native unit for SQL/`BIGINT` columns and +// `Date.now()`. Wire/result references that leave the persistence layer speak +// **ISO-8601 strings**. The middleware performs the number→ISO conversion at +// the boundary; do not mix the two on a single field. + +/** + * Durable store for a thread's full message transcript. + * + * A "thread" is the unit of conversation history. 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 +} + +/** + * 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 (see + * {@link withLocks}). + * + * @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 — what packaged backends return + * (`memoryPersistence`, Drizzle, Prisma, D1). + * + * All four state stores are present. Custom 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..464f0c0a6 --- /dev/null +++ b/packages/ai-persistence/tests/capabilities.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest' +import { EventType, chat, defineChatMiddleware } from '@tanstack/ai' +import type { + AnyTextAdapter, + ChatMiddlewareContext, + StreamChunk, +} from '@tanstack/ai' +import type { LockStore } from '../src/locks' +import { InMemoryLockStore } from '../src/locks' +import { memoryPersistence } from '../src/memory' +import { withLocks, withPersistence } from '../src/middleware' +import { + InterruptsCapability, + LocksCapability, + PersistenceCapability, + getInterrupts, + getLocks, + getPersistence, +} from '../src/capabilities' +import { createInterruptController } from '../src/interrupts' +import type { AIPersistence, InterruptStore } from '../src' + +function mockAdapter(chunks: Array) { + return { + kind: 'text', + name: 'mock', + model: 'test-model', + '~types': {}, + chatStream: () => + (async function* () { + for (const c of chunks) yield c + })(), + structuredOutput: async () => ({ data: {}, rawText: '{}' }), + } as unknown as AnyTextAdapter +} + +async function collect(stream: AsyncIterable) { + const out: Array = [] + for await (const c of stream) out.push(c) + return out +} + +describe('persistence capabilities', () => { + it('provides persistence 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/locks.test.ts b/packages/ai-persistence/tests/locks.test.ts new file mode 100644 index 000000000..32f48fbbe --- /dev/null +++ b/packages/ai-persistence/tests/locks.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { InMemoryLockStore } 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) + }) +}) 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..13c093208 --- /dev/null +++ b/packages/ai-persistence/tests/persistence-types.test-d.ts @@ -0,0 +1,168 @@ +import { expectTypeOf } from 'vitest' +import { + composePersistence, + defineAIPersistence, + memoryPersistence, + withPersistence, + withGenerationPersistence, + withLocks, + InMemoryLockStore, +} from '../src' +import type { LockStore } from '../src/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') 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..6be181f42 100644 --- a/packages/ai-preact/src/index.ts +++ b/packages/ai-preact/src/index.ts @@ -16,6 +16,17 @@ export type { export { fetchServerSentEvents, + localStoragePersistence, + sessionStoragePersistence, + indexedDBPersistence, + StorageUnavailableError, + type ChatClientPersistence, + type ChatPersistedState, + type ChatPersistenceConfig, + type ChatPersistenceOption, + type ChatStorageAdapter, + type WebStoragePersistenceOptions, + type IndexedDBPersistenceOptions, fetchHttpStream, xhrServerSentEvents, xhrHttpStream, diff --git a/packages/ai-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..de33077c0 100644 --- a/packages/ai-react/src/index.ts +++ b/packages/ai-react/src/index.ts @@ -69,6 +69,17 @@ export type { // Re-export from ai-client for convenience export { fetchServerSentEvents, + localStoragePersistence, + sessionStoragePersistence, + indexedDBPersistence, + StorageUnavailableError, + type ChatClientPersistence, + type ChatPersistedState, + type ChatPersistenceConfig, + type ChatPersistenceOption, + type ChatStorageAdapter, + type WebStoragePersistenceOptions, + type IndexedDBPersistenceOptions, fetchHttpStream, xhrServerSentEvents, xhrHttpStream, diff --git a/packages/ai-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-solid/src/index.ts b/packages/ai-solid/src/index.ts index 1d0c517b3..e433084e3 100644 --- a/packages/ai-solid/src/index.ts +++ b/packages/ai-solid/src/index.ts @@ -62,6 +62,17 @@ export type { // Re-export from ai-client for convenience export { fetchServerSentEvents, + localStoragePersistence, + sessionStoragePersistence, + indexedDBPersistence, + StorageUnavailableError, + type ChatClientPersistence, + type ChatPersistedState, + type ChatPersistenceConfig, + type ChatPersistenceOption, + type ChatStorageAdapter, + type WebStoragePersistenceOptions, + type IndexedDBPersistenceOptions, fetchHttpStream, xhrServerSentEvents, xhrHttpStream, diff --git a/packages/ai-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..dca735476 100644 --- a/packages/ai-svelte/src/index.ts +++ b/packages/ai-svelte/src/index.ts @@ -65,6 +65,17 @@ export type { // Re-export from ai-client for convenience export { fetchServerSentEvents, + localStoragePersistence, + sessionStoragePersistence, + indexedDBPersistence, + StorageUnavailableError, + type ChatClientPersistence, + type ChatPersistedState, + type ChatPersistenceConfig, + type ChatPersistenceOption, + type ChatStorageAdapter, + type WebStoragePersistenceOptions, + type IndexedDBPersistenceOptions, fetchHttpStream, xhrServerSentEvents, xhrHttpStream, diff --git a/packages/ai-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..e433084e3 100644 --- a/packages/ai-vue/src/index.ts +++ b/packages/ai-vue/src/index.ts @@ -62,6 +62,17 @@ export type { // Re-export from ai-client for convenience export { fetchServerSentEvents, + localStoragePersistence, + sessionStoragePersistence, + indexedDBPersistence, + StorageUnavailableError, + type ChatClientPersistence, + type ChatPersistedState, + type ChatPersistenceConfig, + type ChatPersistenceOption, + type ChatStorageAdapter, + type WebStoragePersistenceOptions, + type IndexedDBPersistenceOptions, fetchHttpStream, xhrServerSentEvents, xhrHttpStream, diff --git a/packages/ai-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/skills/ai-core/SKILL.md b/packages/ai/skills/ai-core/SKILL.md index 4bf9b64b8..c034c1a65 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, persistence (server/client/backends/custom stores/locks), + custom-backend-integration, and debug-logging. 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' @@ -30,6 +31,7 @@ Always import from the framework package on the client — never from | 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 | +| Persist chats (server/client/backends/custom) | ai-core/persistence/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 | @@ -43,6 +45,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? → ai-core/persistence - 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..8be6ade60 100644 --- a/packages/ai/skills/ai-core/chat-experience/SKILL.md +++ b/packages/ai/skills/ai-core/chat-experience/SKILL.md @@ -17,6 +17,8 @@ sources: - 'TanStack/ai:docs/chat/thinking-content.md' - 'TanStack/ai:docs/advanced/multimodal-content.md' - 'TanStack/ai:docs/resumable-streams/overview.md' + - 'TanStack/ai:docs/chat/persistence.md' + - 'TanStack/ai:docs/persistence/client-persistence.md' --- # Chat Experience @@ -485,6 +487,75 @@ to `sendMessage`: sendMessage('Never mind, do this instead', { whenBusy: 'interrupt' }) ``` +### 8. Browser-Refresh Durability (client persistence) + +By default a `ChatClient` / `useChat` keeps messages in memory only, so a full +page reload loses the conversation. The optional `persistence` option (a +`ChatClientPersistence` adapter) fixes this from the client side: it stores one +combined record — `{ messages, resume? }` (`ChatPersistedState`) — per chat `id`, +so a reload restores the transcript **and** rehydrates any pending interrupt / +rejoins a run that was still streaming. No manual `initialMessages` + `onFinish` +boilerplate. + +Three storage adapters ship from `@tanstack/ai-client`: +`localStoragePersistence` (survives reloads and browser restarts), +`sessionStoragePersistence` (scoped to the tab), and `indexedDBPersistence` +(async, structured-clone storage — no codec needed for `Date`/`Map`/etc.). +Give the chat a stable `threadId` so the reload finds the same record. +Persistence keys on `threadId`; the storage adapters are re-exported from each +framework package, so a single import works: + +```typescript +import { + useChat, + fetchServerSentEvents, + localStoragePersistence, +} from '@tanstack/ai-react' + +// Defaults to the ChatPersistedState shape and a JSON codec, so no type +// argument or serialize/deserialize is needed. indexedDBPersistence stores via +// structured clone (a Date round-trips exactly). +const persistence = localStoragePersistence() + +function Chat() { + const { messages, sendMessage } = useChat({ + threadId: 'support-chat', + connection: fetchServerSentEvents('/api/chat'), + persistence, + }) + // ...render messages, call sendMessage(text) +} +``` + +**Keep large transcripts off the client.** `persistence` also accepts the object +form `{ store, messages?: boolean }`. `messages: false` caches only the tiny +resume pointer (which run to rejoin, which interrupts are pending), so durability +rejoin and interrupt restore still work while the transcript stays off the client +and the server stays authoritative for history. A bare adapter is shorthand for +`{ store, messages: true }`. With `messages: false`, hydrate the reload display +from the server (a loader that reads `messages.loadThread(id)`), since the +delivery log only holds one run. + +**Mid-stream reload rejoin.** If the run was still streaming when the page +reloaded, the client re-attaches instead of showing a frozen half-reply — but +only when the connection is **resumable**: a delivery-durability-backed route +that records the stream and exposes a GET replay handler (see +`docs/resumable-streams/overview.md` and Pattern 1's `durability` adapter). Given +that, `useChat` finds the persisted in-flight run on load and auto-rejoins it via +`joinRun`, replaying from the server's log so the reply finishes where it left +off. No extra client code beyond the resumable connection. + +**Every framework, no extra code.** Durability rides the existing `persistence` +option, so it works identically in `@tanstack/ai-react`, `-solid`, `-vue`, +`-svelte`, `-angular`, and `-preact` — pass `persistence` (and a stable `id`) to +the framework's `useChat` / `createChat` / `injectChat`; nothing is +framework-specific. + +> **Client vs. server durability.** This is the client (per-browser) half. +> The authoritative, multi-user, server-side copy is the `withPersistence` +> middleware — see ai-core/middleware/SKILL.md. The two are independent; use +> both for instant reload restore plus a durable record of record. + ## Common Mistakes ### a. CRITICAL: Using Vercel AI SDK patterns (streamText, generateText) @@ -696,3 +767,4 @@ If not handled, the UI appears to hang with no feedback. - See also: **ai-core/tool-calling/SKILL.md** -- Most chats include tools - See also: **ai-core/adapter-configuration/SKILL.md** -- Adapter choice affects available features - See also: **ai-core/middleware/SKILL.md** -- Use middleware for analytics and lifecycle events +- See also: **ai-core/persistence/SKILL.md** -- Server + client state persistence, backends, custom stores (deeper than Pattern 8) diff --git a/packages/ai/skills/ai-core/middleware/SKILL.md b/packages/ai/skills/ai-core/middleware/SKILL.md index 65c6bf7b8..13948b9d8 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,105 @@ Options: `maxSize` (default 100), `ttl` (default Infinity), `toolNames` (default `keyFn` (custom cache key), `storage` (custom backend like Redis). See `docs/advanced/middleware.md` for custom storage examples. +## Server State Persistence: withPersistence + +`withPersistence(persistence)` (from `@tanstack/ai-persistence`) is a +`ChatMiddleware` that persists **state** for `chat()` — thread messages, run +records (status/timing/usage/errors), and interrupt state — to a backend store. +Add it to the `middleware` array like any other middleware. It never mutates the +chunk stream; replaying a dropped/reloaded _stream_ is a separate transport-layer +concern (see ai-core/chat-experience/SKILL.md resumability, not this middleware). + +```typescript +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { withPersistence } from '@tanstack/ai-persistence' +import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite' + +const persistence = sqlitePersistence({ + url: 'file:.tanstack-ai/state.sqlite', +}) + +export async function POST(request: Request) { + const params = await chatParamsFromRequest(request) + + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages: params.messages, + threadId: params.threadId, + runId: params.runId, + ...(params.resume ? { resume: params.resume } : {}), + middleware: [withPersistence(persistence)], + }) + + return toServerSentEventsResponse(stream) +} +``` + +### Authoritative-history contract + +The middleware treats each request's `messages` as the source of truth for the +thread: + +- **Non-empty `messages`** → on a successful finish (and at an interrupt + boundary) the middleware **overwrites** the entire stored thread with that + array. Post the **complete** transcript, never just the newest message(s) — a + delta would replace and destroy the stored history. +- **Empty `messages`** → the middleware **loads** the stored thread and runs the + turn from the server's copy. This is how you continue a conversation without + resending history from the client. + +### Backends + +Every backend returns an `AIPersistence` you pass straight to +`withPersistence`: + +| Backend | Factory | Import | +| ----------------------------------- | ---------------------------------------------- | ----------------------------------------- | +| In-memory (dev/tests) | `memoryPersistence()` | `@tanstack/ai-persistence` | +| Drizzle SQLite/Postgres (edge-safe) | `drizzlePersistence(db, { provider, schema })` | `@tanstack/ai-persistence-drizzle` | +| Node SQLite convenience factory | `sqlitePersistence({ url })` | `@tanstack/ai-persistence-drizzle/sqlite` | +| Prisma | `prismaPersistence(prisma)` | `@tanstack/ai-persistence-prisma` | +| Cloudflare D1 (state) | `cloudflarePersistence({ d1 })` | `@tanstack/ai-persistence-cloudflare` | + +Locks are separate from state: `withLocks(lockStore)` + e.g. +`createDurableObjectLockStore(env.AI_LOCKS)` from +`@tanstack/ai-persistence-cloudflare`. + +`drizzlePersistence(db, { provider, schema })` is the edge-safe root — +`provider` is `'sqlite'` (any SQLite-compatible Drizzle database, including +Cloudflare D1) or `'pg'` (node-postgres, postgres.js, Neon, PGlite), and `db` +plus the required `schema` must match it (enforced by overloads and a runtime +dialect check). Get the schema by re-exporting the `/sqlite-schema` or +`/pg-schema` subpath (stock tables), emitting an owned copy via +`tanstack-ai-drizzle-schema [--dialect pg]`, or `createDefaultSqliteSchema()` / +`createDefaultPgSchema()`. `sqlitePersistence` is Node-only and lives at the +`/sqlite` subpath. Compose backends per store with +`composePersistence(base, { overrides })`. + +### 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 +657,4 @@ Source: docs/advanced/middleware.md - See also: **ai-core/chat-experience/SKILL.md** -- Middleware hooks into the chat lifecycle - See also: **ai-core/structured-outputs/SKILL.md** -- Middleware now wraps the final structured-output call; use `onStructuredOutputConfig` for JSON-Schema transforms - See also: **ai-core/ag-ui-protocol/SKILL.md** -- Reading the `sandbox.file` / `sandbox.file.diff` `CUSTOM` chunks the sandbox runtime emits alongside these `sandbox` hooks, via `ChatStream`'s typed `KnownCustomEvent` narrowing +- See also: **ai-core/persistence/SKILL.md** -- Full persistence suite (`withPersistence`, client storage, backends, custom stores, locks). This file only sketches server `withPersistence`. diff --git a/packages/ai/skills/ai-core/persistence/SKILL.md b/packages/ai/skills/ai-core/persistence/SKILL.md new file mode 100644 index 000000000..91d2e3fcb --- /dev/null +++ b/packages/ai/skills/ai-core/persistence/SKILL.md @@ -0,0 +1,157 @@ +--- +name: ai-core/persistence +description: > + Durability and state persistence for TanStack AI chats. Routes to server chat + persistence (withPersistence), client persistence (localStorage/IndexedDB), + packaged backends (Drizzle/Prisma/Cloudflare), custom stores, and locks. + 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 (resumable streams). +type: sub-skill +library: tanstack-ai +library_version: '0.10.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/resumable-streams/overview.md' +--- + +# Persistence + +> **Dependency note:** Builds on ai-core 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. + +## Sub-skills + +| Need to... | Read | +| ----------------------------------------------- | ------------------------------------------ | +| Wire server-side chat history, runs, interrupts | ai-core/persistence/server/SKILL.md | +| Survive reloads in the browser | ai-core/persistence/client/SKILL.md | +| Pick Drizzle / Prisma / Cloudflare / memory | ai-core/persistence/backends/SKILL.md | +| Implement or override store interfaces | ai-core/persistence/custom-stores/SKILL.md | +| Multi-instance locks (separate from state) | ai-core/persistence/locks/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 SQL/D1/… | 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: { store, messages: false }` — resume pointer only. +2. **Server:** `withPersistence(backend)` — messages + runs + interrupts. +3. **Route:** delivery durability if mid-stream reconnect matters. +4. **Optional:** `withLocks(distributedLockStore)` 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' +import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite' + +const persistence = sqlitePersistence({ + url: 'file:.tanstack-ai/state.sqlite', +}) + +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 resume pointer)** + +```tsx +import { + useChat, + fetchServerSentEvents, + localStoragePersistence, +} from '@tanstack/ai-react' + +const store = localStoragePersistence() + +function Chat({ threadId }: { threadId: string }) { + const { messages, sendMessage } = useChat({ + threadId, + connection: fetchServerSentEvents('/api/chat'), + persistence: { store, messages: false }, + }) + // ... +} +``` + +With `messages: false`, the client hydrates the transcript from the server on +mount (thread id is the key). Pair with a server load path such as +`reconstructChat` when you need an explicit 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.** Use `withLocks`, not a field on `AIPersistence.stores`. +6. **Schema-first SQL.** Drizzle/Prisma/D1 do not invent migrations for you — own the journal (see backends skill). +7. **Authorize thread access** at the route boundary. + +## Cross-references + +- **ai-core/chat-experience** — `useChat`, SSE, client `persistence` option overview +- **ai-core/middleware** — middleware hooks; `withPersistence` is a ChatMiddleware +- **Resumable streams docs** — delivery durability only diff --git a/packages/ai/skills/ai-core/persistence/backends/SKILL.md b/packages/ai/skills/ai-core/persistence/backends/SKILL.md new file mode 100644 index 000000000..9c0f2d214 --- /dev/null +++ b/packages/ai/skills/ai-core/persistence/backends/SKILL.md @@ -0,0 +1,173 @@ +--- +name: ai-core/persistence/backends +description: > + Choose and wire packaged persistence backends: memoryPersistence, + Drizzle (sqlite/pg/schema-first), Prisma models fragment, Cloudflare D1 + thin wrapper + Durable Object locks. Schema ownership, migrations, compose. + Use when picking a store implementation — not when implementing custom + interfaces (see custom-stores). +type: sub-skill +library: tanstack-ai +library_version: '0.10.0' +sources: + - 'TanStack/ai:docs/persistence/drizzle.md' + - 'TanStack/ai:docs/persistence/prisma.md' + - 'TanStack/ai:docs/persistence/cloudflare.md' + - 'TanStack/ai:docs/persistence/sql-backends.md' + - 'TanStack/ai:docs/persistence/migrations.md' +--- + +# Persistence Backends + +> Builds on **ai-core/persistence/server**. All backends implement +> `ChatPersistence` / `AIPersistence` for `withPersistence(...)`. + +## Quick pick + +| Backend | Factory | Import | Best for | +| --------------------- | ---------------------------------------------------- | ----------------------------------------- | ------------------------------ | +| In-memory | `memoryPersistence()` | `@tanstack/ai-persistence` | Dev, tests | +| Drizzle SQLite (Node) | `sqlitePersistence({ url })` | `@tanstack/ai-persistence-drizzle/sqlite` | Local/prod file SQLite | +| Drizzle (edge-safe) | `drizzlePersistence(db, { provider, schema })` | `@tanstack/ai-persistence-drizzle` | D1, libsql, any BYO Drizzle | +| Drizzle Postgres | `drizzlePersistence(db, { provider: 'pg', schema })` | same | Neon, node-postgres, PGlite, … | +| Prisma | `prismaPersistence(prisma)` | `@tanstack/ai-persistence-prisma` | Existing Prisma apps | +| Cloudflare D1 | `cloudflarePersistence({ d1 })` | `@tanstack/ai-persistence-cloudflare` | Workers + stock schema | + +Every packaged backend provides **all four** state stores: messages, runs, +interrupts, metadata. Locks are separate (see **persistence/locks**). + +## In-memory + +```ts +import { memoryPersistence, withPersistence } from '@tanstack/ai-persistence' + +middleware: [withPersistence(memoryPersistence())] +``` + +Process-local only. Fine for demos and unit tests; not multi-instance. + +## Drizzle (schema-first) + +The package **does not ship SQL migrations**. You own the schema file and +drizzle-kit journal. + +### Local Node convenience + +```ts +import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite' + +const persistence = sqlitePersistence({ + url: 'file:.tanstack-ai/state.sqlite', // or ':memory:' + // ensureTables: true by default — bootstrap only, not production migrations +}) +``` + +Production: emit schema, migrate with drizzle-kit, pass +`{ schema, ensureTables: false }`. + +### Stock tables (no copy) + +```ts +// src/db/tanstack-ai-schema.ts +export * from '@tanstack/ai-persistence-drizzle/sqlite-schema' +// or .../pg-schema for Postgres +``` + +Add to drizzle-kit `schema` paths → `generate` / `migrate`. + +### Owned starter + +```bash +pnpm exec tanstack-ai-drizzle-schema --out src/db +# Postgres: --dialect pg +``` + +Rename tables/columns, add nullable/defaulted app columns (e.g. `user_id`), +tune indexes. Keep contract column **data shapes**. + +### Runtime (edge-safe root) + +```ts +import { drizzlePersistence } from '@tanstack/ai-persistence-drizzle' +import { schema } from './db/tanstack-ai-schema' +import { db } from './db' + +export const persistence = drizzlePersistence(db, { + provider: 'sqlite', // or 'pg' + schema, +}) +``` + +`provider` must match `db` dialect (compile-time overloads + runtime check). +Table objects are **injected** — physical SQL names come from your schema. + +### Cloudflare D1 + +D1 is SQLite. Prefer the same schema-first path: + +```ts +import { drizzle } from 'drizzle-orm/d1' +import { drizzlePersistence } from '@tanstack/ai-persistence-drizzle' +import { schema } from './db/tanstack-ai-schema' + +export function createPersistence(env: { AI_STATE: D1Database }) { + return drizzlePersistence(drizzle(env.AI_STATE, { schema }), { + provider: 'sqlite', + schema, + }) +} +``` + +Or stock schema via `cloudflarePersistence({ d1 })` — still migrate tables +yourself (Wrangler `migrations_dir` ← your drizzle-kit output). The Cloudflare +package does **not** ship D1 SQL. + +## Prisma + +```bash +pnpm exec tanstack-ai-prisma-models --out prisma/schema +# merge fragment into schema, then: +pnpm prisma migrate dev +pnpm prisma generate +``` + +```ts +import { PrismaClient } from '@prisma/client' +import { prismaPersistence } from '@tanstack/ai-persistence-prisma' + +const prisma = new PrismaClient() +export const persistence = prismaPersistence(prisma) +// or map renamed models via the package's model map options +``` + +## Compose overrides + +Swap or remove individual stores without rewriting the backend: + +```ts +import { composePersistence } from '@tanstack/ai-persistence' +import { cloudflarePersistence } from '@tanstack/ai-persistence-cloudflare' + +return composePersistence(cloudflarePersistence({ d1 }), { + overrides: { + interrupts: myInterruptStore, + metadata: false, // drop store + }, +}) +``` + +Composition is **not** a distributed transaction across systems. + +## Migrations discipline + +1. Prefer re-export stock schema / models when you do not customize. +2. Emit/copy starters only when you need renames or extra columns. +3. Generate DDL with **your** tool (drizzle-kit, Prisma migrate). +4. Apply migrations before deploying code that depends on new columns. +5. Do not dual-maintain hand SQL next to a Drizzle/Prisma schema. + +## Cross-references + +- **ai-core/persistence/server** — `withPersistence` wiring +- **ai-core/persistence/custom-stores** — full custom backends +- **ai-core/persistence/locks** — DO / in-memory locks diff --git a/packages/ai/skills/ai-core/persistence/client/SKILL.md b/packages/ai/skills/ai-core/persistence/client/SKILL.md new file mode 100644 index 000000000..bb07acfda --- /dev/null +++ b/packages/ai/skills/ai-core/persistence/client/SKILL.md @@ -0,0 +1,129 @@ +--- +name: ai-core/persistence/client +description: > + Browser chat persistence on useChat / ChatClient: localStoragePersistence, + sessionStoragePersistence, indexedDBPersistence. Client-authoritative + (full transcript) vs server-authoritative (messages: false resume pointer). + Reload restore, pending interrupts, mid-stream rejoin with delivery + durability. Use for SPA reload durability — NOT server SQL history alone. +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/persistence** and **ai-core/chat-experience**. + +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 — resume pointer only (server-authoritative) + +```tsx +const store = localStoragePersistence() + +function Chat({ threadId }: { threadId: string }) { + const { messages, sendMessage } = useChat({ + threadId, + connection: fetchServerSentEvents('/api/chat'), + persistence: { store, messages: false }, + }) + // ... +} +``` + +Only the tiny resume pointer is cached (which run to rejoin, which interrupts +are pending). Transcript stays off the client. + +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 storage (mode A) or server (mode B). +2. **Paused on interrupt** — approval UI restored from resume pointer. +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` + +Persistence keys on `threadId`. Without a stable id, each load is a new chat. +Generate 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: `messages: false` 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 `messages: false` + 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-core/persistence/server** — authoritative server half +- **ai-core/chat-experience** — `useChat`, resumable connections +- Resumable streams docs — mid-stream rejoin diff --git a/packages/ai/skills/ai-core/persistence/custom-stores/SKILL.md b/packages/ai/skills/ai-core/persistence/custom-stores/SKILL.md new file mode 100644 index 000000000..9dd97551f --- /dev/null +++ b/packages/ai/skills/ai-core/persistence/custom-stores/SKILL.md @@ -0,0 +1,220 @@ +--- +name: ai-core/persistence/custom-stores +description: > + Implement custom MessageStore, RunStore, InterruptStore, MetadataStore for + @tanstack/ai-persistence. defineAIPersistence, composePersistence overrides, + critical invariants (full-replace saveThread, insert-if-absent createOrResume + and interrupt create), authorize thread access, runPersistenceConformance + testkit. Use when infrastructure is not Drizzle/Prisma/D1 or selected stores + must live in an app-owned database. +type: sub-skill +library: tanstack-ai +library_version: '0.10.0' +sources: + - 'TanStack/ai:docs/persistence/custom-stores.md' + - 'TanStack/ai:docs/persistence/controls.md' + - 'TanStack/ai:packages/ai-persistence/src/types.ts' +--- + +# Custom Persistence Stores + +> Builds on **ai-core/persistence** and **ai-core/persistence/server**. + +Prefer packaged backends when they fit. Implement custom stores when: + +- Your DB/ORM is not Drizzle/Prisma (or you already have tables), +- Only some keys (e.g. interrupts) must live in an app DB, +- You need multi-tenant columns, encryption, or outbox hooks inside the store. + +## Choose a shape + +```ts +import { defineAIPersistence } from '@tanstack/ai-persistence' +import type { + MessageStore, + RunStore, + InterruptStore, + MetadataStore, +} from '@tanstack/ai-persistence' + +// Sparse is fine — only implement what you need +export const persistence = defineAIPersistence({ + stores: { + messages, // required for withPersistence / reconstructChat + runs, // required if you have interrupts + interrupts, + // metadata optional + }, +}) +``` + +| Shape | Contents | +| -------------------------- | ------------------------------------------------ | +| `ChatTranscriptStores` | `messages` (+ optional runs/interrupts/metadata) | +| `ChatWithInterruptsStores` | `messages` + `runs` + `interrupts` | +| `ChatPersistenceStores` | all four (packaged backend shape) | + +`defineAIPersistence` preserves exact keys and rejects unknown keys at runtime. + +## 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. + +### `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(scope: string, key: string): Promise + set(scope: string, key: string, value: unknown): Promise + delete(scope: string, key: string): Promise +} +``` + +- Identity is **two fields** `(scope, key)` — do not join with `:` (collision). +- Stored `null` is type-indistinguishable from absence; wrap if you must + persist real null (`{ value: null }`). +- SQL backends often reject nullish `set` (NOT NULL JSON columns) — match that + or document your semantics. + +## Minimal message store example + +```ts +import type { MessageStore } from '@tanstack/ai-persistence' +import type { ModelMessage } from '@tanstack/ai' + +const threads = new Map>() + +export const messages: MessageStore = { + async loadThread(threadId) { + return [...(threads.get(threadId) ?? [])] + }, + async saveThread(threadId, next) { + threads.set(threadId, [...next]) + }, +} +``` + +For durable DBs, preserve the same semantics with upserts / full-row replace. + +## Override a packaged backend + +```ts +import { composePersistence } from '@tanstack/ai-persistence' +import { drizzlePersistence } from '@tanstack/ai-persistence-drizzle' +import { myInterrupts } from './my-interrupts' + +export const persistence = composePersistence(base, { + overrides: { interrupts: myInterrupts }, +}) +``` + +Only listed keys move; others stay on the base. No cross-store transaction. + +## 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 for custom backends) + +```ts +import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' +import { myPersistence } from '../src/persistence' + +runPersistenceConformance('my-backend', () => myPersistence()) + +// Intentional omissions: +// runPersistenceConformance('msgs-only', () => p, { skip: ['runs', 'interrupts', 'metadata'] }) +``` + +The testkit is the compatibility gate: round-trips, empty-thread `[]`, +createOrResume idempotency, interrupt insert-if-absent, list ordering, etc. +A missing store that is not listed in `skip` fails loudly. + +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: `list*` without stable `requestedAt` order + +Middleware and tests assume ascending order. + +### HIGH: Skipping testkit + +Silent semantic drift shows up as stuck approvals or wiped history in prod. + +## Cross-references + +- **ai-core/persistence/backends** — use packaged first +- **ai-core/persistence/server** — when middleware calls each store +- **ai-core/persistence/locks** — not a state store diff --git a/packages/ai/skills/ai-core/persistence/locks/SKILL.md b/packages/ai/skills/ai-core/persistence/locks/SKILL.md new file mode 100644 index 000000000..1289a866e --- /dev/null +++ b/packages/ai/skills/ai-core/persistence/locks/SKILL.md @@ -0,0 +1,104 @@ +--- +name: ai-core/persistence/locks +description: > + LockStore and withLocks for multi-instance coordination in TanStack AI. + Separate from AIPersistence state stores. InMemoryLockStore vs Cloudflare + Durable Object locks, 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/persistence/controls.md' + - 'TanStack/ai:docs/persistence/cloudflare.md' +--- + +# Persistence Locks + +> Builds on **ai-core/persistence**. Locks are **not** part of +> `AIPersistence.stores` and are **not** composed with +> `composePersistence`. + +## 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 { + withPersistence, + withLocks, + InMemoryLockStore, +} from '@tanstack/ai-persistence' +import { createDurableObjectLockStore } from '@tanstack/ai-persistence-cloudflare' + +middleware: [ + withPersistence(persistence), + withLocks(new InMemoryLockStore()), // single process + // withLocks(createDurableObjectLockStore(env.AI_LOCKS)), // multi-instance +] +``` + +`withLocks` provides `LocksCapability` for downstream middleware (e.g. +sandbox). Order: usually state first, locks alongside or after depending on +who consumes the capability. + +## Implementations + +| Store | Package | Use when | +| ---------------------------------- | ------------------------------------- | ---------------------------- | +| `InMemoryLockStore` | `@tanstack/ai-persistence` | Single process, tests, local | +| `createDurableObjectLockStore(ns)` | `@tanstack/ai-persistence-cloudflare` | Multiple Workers / instances | + +### Cloudflare Durable Objects + +```ts +import { createDurableObjectLockStore } from '@tanstack/ai-persistence-cloudflare' + +export { CloudflareLockDurableObject } from '@tanstack/ai-persistence-cloudflare' + +const locks = createDurableObjectLockStore(env.AI_LOCKS, { + leaseDurationMs: 30_000, + retryDelayMs: 50, +}) +``` + +Configure DO bindings and Wrangler migration tags for the lock class. This is +unrelated to D1 schema migrations for chat tables. + +## 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. + +## Common mistakes + +### HIGH: Putting locks on `AIPersistence.stores` + +Not supported. Use `withLocks`. + +### HIGH: `InMemoryLockStore` across multiple processes + +No mutual exclusion between machines — use DO locks or another distributed +backend. + +### MEDIUM: Ignoring lease abort + +Continuing work after losing the lease races other owners. + +## Cross-references + +- **ai-core/persistence/server** — state middleware +- **ai-core/persistence/backends** — D1 + DO package split diff --git a/packages/ai/skills/ai-core/persistence/server/SKILL.md b/packages/ai/skills/ai-core/persistence/server/SKILL.md new file mode 100644 index 000000000..3e2c497f4 --- /dev/null +++ b/packages/ai/skills/ai-core/persistence/server/SKILL.md @@ -0,0 +1,169 @@ +--- +name: ai-core/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 persistence/client) and NOT stream reconnect alone. +type: sub-skill +library: tanstack-ai +library_version: '0.10.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-core/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' +import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite' + +const persistence = sqlitePersistence({ + url: 'file:.data/chat.sqlite', +}) + +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. + +## 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 — +what packaged backends return). Prefer those over a sparse bag type. + +## 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`. Packaged backends include both. + +### MEDIUM: Expecting `withPersistence` to reconnect a dropped stream + +That is delivery durability (resumable streams), not state persistence. + +## Cross-references + +- **ai-core/persistence** — layers and recommended stack +- **ai-core/persistence/backends** — factories and schema ownership +- **ai-core/persistence/custom-stores** — implement interfaces +- **ai-core/persistence/client** — browser half +- **ai-core/persistence/locks** — multi-instance coordination 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/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/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/pnpm-lock.yaml b/pnpm-lock.yaml index 45e6aa0f4..baa27b4e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -165,6 +165,146 @@ importers: specifier: ^7.3.3 version: 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) + examples/persistent-chat-drizzle: + dependencies: + '@tanstack/ai': + specifier: workspace:* + version: link:../../packages/ai + '@tanstack/ai-client': + specifier: workspace:* + version: link:../../packages/ai-client + '@tanstack/ai-openai': + specifier: workspace:* + version: link:../../packages/ai-openai + '@tanstack/ai-persistence': + specifier: workspace:* + version: link:../../packages/ai-persistence + '@tanstack/ai-persistence-drizzle': + specifier: workspace:* + version: link:../../packages/ai-persistence-drizzle + '@tanstack/ai-react': + specifier: workspace:* + version: link:../../packages/ai-react + '@tanstack/react-router': + specifier: ^1.158.4 + version: 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/react-start': + specifier: ^1.159.0 + 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/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)) + drizzle-orm: + specifier: ^0.45.0 + version: 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)) + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + zod: + specifier: ^4.2.0 + version: 4.3.6 + devDependencies: + '@types/node': + specifier: ^24.10.1 + version: 24.10.3 + '@types/react': + specifier: ^19.2.7 + version: 19.2.7 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.7) + '@vitejs/plugin-react': + specifier: ^5.1.2 + version: 5.1.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)) + drizzle-kit: + specifier: ^0.31.0 + version: 0.31.10 + nitro: + specifier: 3.0.260610-beta + 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.4.2)(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@3.3.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) + typescript: + specifier: 5.9.3 + version: 5.9.3 + vite: + specifier: ^8.1.4 + version: 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) + + examples/persistent-chat-prisma: + dependencies: + '@prisma/adapter-better-sqlite3': + specifier: ^7.0.0 + version: 7.9.0 + '@prisma/client': + specifier: ^7.0.0 + version: 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) + '@tanstack/ai': + specifier: workspace:* + version: link:../../packages/ai + '@tanstack/ai-client': + specifier: workspace:* + version: link:../../packages/ai-client + '@tanstack/ai-openai': + specifier: workspace:* + version: link:../../packages/ai-openai + '@tanstack/ai-persistence': + specifier: workspace:* + version: link:../../packages/ai-persistence + '@tanstack/ai-persistence-prisma': + specifier: workspace:* + version: link:../../packages/ai-persistence-prisma + '@tanstack/ai-react': + specifier: workspace:* + version: link:../../packages/ai-react + '@tanstack/react-router': + specifier: ^1.158.4 + version: 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/react-start': + specifier: ^1.159.0 + 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/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)) + dotenv: + specifier: ^17.2.3 + version: 17.4.2 + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + zod: + specifier: ^4.2.0 + version: 4.3.6 + devDependencies: + '@types/node': + specifier: ^24.10.1 + version: 24.10.3 + '@types/react': + specifier: ^19.2.7 + version: 19.2.7 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.7) + '@vitejs/plugin-react': + specifier: ^5.1.2 + version: 5.1.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)) + nitro: + specifier: 3.0.260610-beta + 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.4.2)(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@3.3.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) + prisma: + specifier: ^7.0.0 + version: 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: + specifier: 5.9.3 + version: 5.9.3 + vite: + specifier: ^8.1.4 + version: 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) + examples/sandbox-cloudflare: dependencies: '@cloudflare/sandbox': @@ -357,7 +497,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 +654,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 +748,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 +882,12 @@ importers: '@tanstack/ai-openrouter': specifier: workspace:* version: link:../../packages/ai-openrouter + '@tanstack/ai-persistence': + specifier: workspace:* + version: link:../../packages/ai-persistence + '@tanstack/ai-persistence-drizzle': + specifier: workspace:* + version: link:../../packages/ai-persistence-drizzle '@tanstack/ai-react': specifier: workspace:* version: link:../../packages/ai-react @@ -798,7 +944,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 +1047,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 +1181,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 +1278,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.3.16)(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 +2217,78 @@ importers: specifier: ^4.2.0 version: 4.3.6 + packages/ai-persistence: + devDependencies: + '@tanstack/ai': + specifier: workspace:* + version: link:../ai + '@vitest/coverage-v8': + specifier: 4.0.14 + version: 4.0.14(supports-color@7.2.0)(vitest@4.1.10) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.0.14)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.19))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) + + packages/ai-persistence-cloudflare: + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20260317.1 + version: 4.20260317.1 + '@tanstack/ai': + specifier: workspace:* + version: link:../ai + '@tanstack/ai-persistence': + specifier: workspace:* + version: link:../ai-persistence + '@tanstack/ai-persistence-drizzle': + specifier: workspace:* + version: link:../ai-persistence-drizzle + '@vitest/coverage-v8': + specifier: 4.0.14 + version: 4.0.14(supports-color@7.2.0)(vitest@4.1.10) + drizzle-orm: + specifier: ^0.45.0 + version: 0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.3.16)(@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@7.0.2))(typescript@7.0.2))(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@7.0.2)) + miniflare: + specifier: ^4.20260609.0 + version: 4.20260617.1 + + packages/ai-persistence-drizzle: + devDependencies: + '@electric-sql/pglite': + specifier: ^0.3.0 + version: 0.3.16 + '@tanstack/ai': + specifier: workspace:* + version: link:../ai + '@tanstack/ai-persistence': + specifier: workspace:* + version: link:../ai-persistence + '@vitest/coverage-v8': + specifier: 4.0.14 + version: 4.0.14(supports-color@7.2.0)(vitest@4.1.10) + drizzle-orm: + specifier: ^0.45.0 + version: 0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.3.16)(@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@7.0.2))(typescript@7.0.2))(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@7.0.2)) + + packages/ai-persistence-prisma: + devDependencies: + '@prisma/client': + specifier: ^6.19.3 + version: 6.19.3(prisma@6.19.3(typescript@7.0.2))(typescript@7.0.2) + '@tanstack/ai': + specifier: workspace:* + version: link:../ai + '@tanstack/ai-persistence': + specifier: workspace:* + version: link:../ai-persistence + '@vitest/coverage-v8': + specifier: 4.0.14 + version: 4.0.14(supports-color@7.2.0)(vitest@4.1.10) + prisma: + specifier: ^6.19.3 + version: 6.19.3(typescript@7.0.2) + packages/ai-preact: dependencies: '@tanstack/ai-client': @@ -2638,7 +2856,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 +2968,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 +2983,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 +4175,26 @@ packages: '@deno/shim-deno@0.19.2': resolution: {integrity: sha512-q3VTHl44ad8T2Tw2SpeAvghdGOjlnLPDNO2cpOxwMrBE/PVas6geWpbpIgrM+czOCH0yejp0yi8OaTuB+NU40Q==} + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + + '@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.3.16': + resolution: {integrity: sha512-mZkZfOd9OqTMHsK+1cje8OSzfAQcpD7JmILXTl5ahdempjUDdmg4euf1biDex5/LfQIDJ3gvCu6qDgdnDxfJmA==} + + '@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==} @@ -3991,6 +4229,14 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + '@esbuild/aix-ppc64@0.20.2': resolution: {integrity: sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==} engines: {node: '>=12'} @@ -4021,6 +4267,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.20.2': resolution: {integrity: sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==} engines: {node: '>=12'} @@ -4051,6 +4303,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.20.2': resolution: {integrity: sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==} engines: {node: '>=12'} @@ -4081,6 +4339,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.20.2': resolution: {integrity: sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==} engines: {node: '>=12'} @@ -4111,6 +4375,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.20.2': resolution: {integrity: sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==} engines: {node: '>=12'} @@ -4141,6 +4411,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.20.2': resolution: {integrity: sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==} engines: {node: '>=12'} @@ -4171,6 +4447,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.20.2': resolution: {integrity: sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==} engines: {node: '>=12'} @@ -4201,6 +4483,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.20.2': resolution: {integrity: sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==} engines: {node: '>=12'} @@ -4231,6 +4519,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.20.2': resolution: {integrity: sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==} engines: {node: '>=12'} @@ -4261,6 +4555,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.20.2': resolution: {integrity: sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==} engines: {node: '>=12'} @@ -4291,6 +4591,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.20.2': resolution: {integrity: sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==} engines: {node: '>=12'} @@ -4321,6 +4627,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.20.2': resolution: {integrity: sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==} engines: {node: '>=12'} @@ -4351,6 +4663,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.20.2': resolution: {integrity: sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==} engines: {node: '>=12'} @@ -4381,6 +4699,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.20.2': resolution: {integrity: sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==} engines: {node: '>=12'} @@ -4411,6 +4735,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.20.2': resolution: {integrity: sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==} engines: {node: '>=12'} @@ -4441,6 +4771,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.20.2': resolution: {integrity: sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==} engines: {node: '>=12'} @@ -4471,6 +4807,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.20.2': resolution: {integrity: sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==} engines: {node: '>=12'} @@ -4525,6 +4867,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.20.2': resolution: {integrity: sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==} engines: {node: '>=12'} @@ -4579,6 +4927,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.20.2': resolution: {integrity: sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==} engines: {node: '>=12'} @@ -4633,6 +4987,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.20.2': resolution: {integrity: sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==} engines: {node: '>=12'} @@ -4663,6 +5023,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.20.2': resolution: {integrity: sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==} engines: {node: '>=12'} @@ -4693,6 +5059,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.20.2': resolution: {integrity: sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==} engines: {node: '>=12'} @@ -4723,6 +5095,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.20.2': resolution: {integrity: sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==} engines: {node: '>=12'} @@ -6711,6 +7089,99 @@ packages: '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@prisma/adapter-better-sqlite3@7.9.0': + resolution: {integrity: sha512-svUJDASC2X1MzFG1Wl2JQ5HcHVTxN8zZYj7quRIJ6P+Aa/+OXVkYooCu7WkypNceb91SoGbVeFiP8iMD3/3Ahg==} + + '@prisma/client-runtime-utils@7.9.0': + resolution: {integrity: sha512-kMVmS4ZEy3xlkca+TfxOEm/ToVVlOS2x1Tc6/wIRf/HfczBqENtSPcKszy4ZpFNzjJ8SRKvlU5V0rrpoFw2KOg==} + + '@prisma/client@6.19.3': + resolution: {integrity: sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==} + engines: {node: '>=18.18'} + peerDependencies: + prisma: '*' + typescript: '>=5.1.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/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@6.19.3': + resolution: {integrity: sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==} + + '@prisma/config@7.9.0': + resolution: {integrity: sha512-CsoK2mhl0u+N4/8V+XroQMOUNIic4isqD+E2HBG8l1yGEKo62CFDu3FHo0FdwItjl6XkW+omA1STSzeN1DAXlg==} + + '@prisma/debug@6.19.3': + resolution: {integrity: sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==} + + '@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/driver-adapter-utils@7.9.0': + resolution: {integrity: sha512-fFXujitfMyjk3kOd1Tbs5FXBm6i2OWwEhaP5lHgkUM99jHpPEQwCWj+z/WKPFq6EDMThE1zGzSlVegtR0Pmu2w==} + + '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': + resolution: {integrity: sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==} + + '@prisma/engines-version@7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad': + resolution: {integrity: sha512-2BsPPFksz3CQUXG6af3rVCtJKg6+JJGJTtfgu2fU8DdXhOfkBjulCq8mwybCd6ge0/jhZq2kOtLAbmUDMyI1nA==} + + '@prisma/engines@6.19.3': + resolution: {integrity: sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==} + + '@prisma/engines@7.9.0': + resolution: {integrity: sha512-lDWJp/pgSWCLfYsupmmNo96jfsbQnH1yjia8XVM2Kh8nRZhD0bQU2jCHuy3ZTPMLR3apRD3k145ybENalAYjYw==} + + '@prisma/fetch-engine@6.19.3': + resolution: {integrity: sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==} + + '@prisma/fetch-engine@7.9.0': + resolution: {integrity: sha512-F0XlIgjbE3EywRVR/HpCerNI/dxo40vK66tHcWpsWYwH/Jk9+FsICEzATeMsZ7bdnpZz93hkD4sAb5rKLsCCpA==} + + '@prisma/get-platform@6.19.3': + resolution: {integrity: sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==} + + '@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 +9214,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 +9609,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 +9690,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 +10021,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 +10430,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 +10592,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==} @@ -10166,6 +10712,14 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + c12@3.1.0: + resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} + peerDependencies: + magicast: ^0.3.5 + peerDependenciesMeta: + magicast: + optional: true + c12@3.3.3: resolution: {integrity: sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==} peerDependencies: @@ -10174,6 +10728,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 +10862,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 +11019,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 +11152,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 +11164,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 +11300,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 +11311,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 +11344,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 +11464,110 @@ 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-kit@0.31.10: + resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} + hasBin: true + + drizzle-orm@0.45.2: + resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + dts-resolver@2.1.3: resolution: {integrity: sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw==} engines: {node: '>=20.19.0'} @@ -10896,6 +11598,12 @@ 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==} + + effect@3.21.0: + resolution: {integrity: sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==} + ejs@5.0.1: resolution: {integrity: sha512-COqBPFMxuPTPspXl2DkVYaDS3HtrD1GpzOGkNTJ1IYkifq/r9h8SVEFrjA3D9/VJGOEoMQcrlhpntcSUrM8k6A==} engines: {node: '>=0.12.18'} @@ -10904,6 +11612,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 +11666,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 @@ -11021,6 +11736,11 @@ packages: esbuild: '>=0.12' solid-js: '>= 1.0' + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.20.2: resolution: {integrity: sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==} engines: {node: '>=12'} @@ -11189,6 +11909,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 +12026,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 +12053,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 +12137,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 +12286,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 +12346,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 +12408,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 +12857,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 +13485,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 +13850,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 +13959,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 +13978,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 +14065,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==} @@ -13706,9 +14486,15 @@ packages: pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + perfect-debounce@2.0.0: resolution: {integrity: sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==} + 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 +14604,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 +14651,29 @@ packages: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + prisma@6.19.3: + resolution: {integrity: sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==} + engines: {node: '>=18.18'} + hasBin: true + peerDependencies: + typescript: '>=5.1.0' + peerDependenciesMeta: + typescript: + optional: true + + 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 +14699,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 +14762,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 +14818,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 +15045,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 +15098,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 +15124,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 +15221,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 +15295,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 +15454,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 +15560,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 +15683,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 +16015,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 +16461,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 +17090,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 +17742,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 +17986,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 +17995,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 +18004,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 +18179,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 +18204,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 +18525,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 +18551,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 +18573,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 +18886,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 +18913,20 @@ snapshots: '@deno/shim-deno-test': 0.5.0 which: 4.0.0 + '@drizzle-team/brocli@0.10.2': {} + + '@electric-sql/pglite-socket@0.1.3(@electric-sql/pglite@0.4.3)': + dependencies: + '@electric-sql/pglite': 0.4.3 + + '@electric-sql/pglite-tools@0.3.3(@electric-sql/pglite@0.4.3)': + dependencies: + '@electric-sql/pglite': 0.4.3 + + '@electric-sql/pglite@0.3.16': {} + + '@electric-sql/pglite@0.4.3': {} + '@elevenlabs/client@1.3.1(@types/dom-mediacapture-record@1.0.22)': dependencies: '@elevenlabs/types': 0.9.1 @@ -18098,6 +18983,16 @@ snapshots: dependencies: tslib: 2.8.1 + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.14.0 + '@esbuild/aix-ppc64@0.20.2': optional: true @@ -18113,6 +19008,9 @@ snapshots: '@esbuild/aix-ppc64@0.28.1': optional: true + '@esbuild/android-arm64@0.18.20': + optional: true + '@esbuild/android-arm64@0.20.2': optional: true @@ -18128,6 +19026,9 @@ snapshots: '@esbuild/android-arm64@0.28.1': optional: true + '@esbuild/android-arm@0.18.20': + optional: true + '@esbuild/android-arm@0.20.2': optional: true @@ -18143,6 +19044,9 @@ snapshots: '@esbuild/android-arm@0.28.1': optional: true + '@esbuild/android-x64@0.18.20': + optional: true + '@esbuild/android-x64@0.20.2': optional: true @@ -18158,6 +19062,9 @@ snapshots: '@esbuild/android-x64@0.28.1': optional: true + '@esbuild/darwin-arm64@0.18.20': + optional: true + '@esbuild/darwin-arm64@0.20.2': optional: true @@ -18173,6 +19080,9 @@ snapshots: '@esbuild/darwin-arm64@0.28.1': optional: true + '@esbuild/darwin-x64@0.18.20': + optional: true + '@esbuild/darwin-x64@0.20.2': optional: true @@ -18188,6 +19098,9 @@ snapshots: '@esbuild/darwin-x64@0.28.1': optional: true + '@esbuild/freebsd-arm64@0.18.20': + optional: true + '@esbuild/freebsd-arm64@0.20.2': optional: true @@ -18203,6 +19116,9 @@ snapshots: '@esbuild/freebsd-arm64@0.28.1': optional: true + '@esbuild/freebsd-x64@0.18.20': + optional: true + '@esbuild/freebsd-x64@0.20.2': optional: true @@ -18218,6 +19134,9 @@ snapshots: '@esbuild/freebsd-x64@0.28.1': optional: true + '@esbuild/linux-arm64@0.18.20': + optional: true + '@esbuild/linux-arm64@0.20.2': optional: true @@ -18233,6 +19152,9 @@ snapshots: '@esbuild/linux-arm64@0.28.1': optional: true + '@esbuild/linux-arm@0.18.20': + optional: true + '@esbuild/linux-arm@0.20.2': optional: true @@ -18248,6 +19170,9 @@ snapshots: '@esbuild/linux-arm@0.28.1': optional: true + '@esbuild/linux-ia32@0.18.20': + optional: true + '@esbuild/linux-ia32@0.20.2': optional: true @@ -18263,6 +19188,9 @@ snapshots: '@esbuild/linux-ia32@0.28.1': optional: true + '@esbuild/linux-loong64@0.18.20': + optional: true + '@esbuild/linux-loong64@0.20.2': optional: true @@ -18278,6 +19206,9 @@ snapshots: '@esbuild/linux-loong64@0.28.1': optional: true + '@esbuild/linux-mips64el@0.18.20': + optional: true + '@esbuild/linux-mips64el@0.20.2': optional: true @@ -18293,6 +19224,9 @@ snapshots: '@esbuild/linux-mips64el@0.28.1': optional: true + '@esbuild/linux-ppc64@0.18.20': + optional: true + '@esbuild/linux-ppc64@0.20.2': optional: true @@ -18308,6 +19242,9 @@ snapshots: '@esbuild/linux-ppc64@0.28.1': optional: true + '@esbuild/linux-riscv64@0.18.20': + optional: true + '@esbuild/linux-riscv64@0.20.2': optional: true @@ -18323,6 +19260,9 @@ snapshots: '@esbuild/linux-riscv64@0.28.1': optional: true + '@esbuild/linux-s390x@0.18.20': + optional: true + '@esbuild/linux-s390x@0.20.2': optional: true @@ -18338,6 +19278,9 @@ snapshots: '@esbuild/linux-s390x@0.28.1': optional: true + '@esbuild/linux-x64@0.18.20': + optional: true + '@esbuild/linux-x64@0.20.2': optional: true @@ -18365,6 +19308,9 @@ snapshots: '@esbuild/netbsd-arm64@0.28.1': optional: true + '@esbuild/netbsd-x64@0.18.20': + optional: true + '@esbuild/netbsd-x64@0.20.2': optional: true @@ -18392,6 +19338,9 @@ snapshots: '@esbuild/openbsd-arm64@0.28.1': optional: true + '@esbuild/openbsd-x64@0.18.20': + optional: true + '@esbuild/openbsd-x64@0.20.2': optional: true @@ -18419,6 +19368,9 @@ snapshots: '@esbuild/openharmony-arm64@0.28.1': optional: true + '@esbuild/sunos-x64@0.18.20': + optional: true + '@esbuild/sunos-x64@0.20.2': optional: true @@ -18434,6 +19386,9 @@ snapshots: '@esbuild/sunos-x64@0.28.1': optional: true + '@esbuild/win32-arm64@0.18.20': + optional: true + '@esbuild/win32-arm64@0.20.2': optional: true @@ -18449,6 +19404,9 @@ snapshots: '@esbuild/win32-arm64@0.28.1': optional: true + '@esbuild/win32-ia32@0.18.20': + optional: true + '@esbuild/win32-ia32@0.20.2': optional: true @@ -18464,6 +19422,9 @@ snapshots: '@esbuild/win32-ia32@0.28.1': optional: true + '@esbuild/win32-x64@0.18.20': + optional: true + '@esbuild/win32-x64@0.20.2': optional: true @@ -20425,7 +21386,181 @@ snapshots: '@sindresorhus/is': 7.1.1 supports-color: 10.2.2 - '@poppinss/exception@1.2.3': {} + '@poppinss/exception@1.2.3': {} + + '@prisma/adapter-better-sqlite3@7.9.0': + dependencies: + '@prisma/driver-adapter-utils': 7.9.0 + better-sqlite3: 12.11.1 + + '@prisma/client-runtime-utils@7.9.0': {} + + '@prisma/client@6.19.3(prisma@6.19.3(typescript@7.0.2))(typescript@7.0.2)': + optionalDependencies: + prisma: 6.19.3(typescript@7.0.2) + typescript: 7.0.2 + + '@prisma/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)': + 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/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 + + '@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@7.0.2))(typescript@7.0.2)': + dependencies: + '@prisma/client-runtime-utils': 7.9.0 + optionalDependencies: + 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@7.0.2) + typescript: 7.0.2 + optional: true + + '@prisma/config@6.19.3': + dependencies: + c12: 3.1.0 + deepmerge-ts: 7.1.5 + effect: 3.21.0 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + '@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 + + '@prisma/debug@6.19.3': {} + + '@prisma/debug@7.2.0': {} + + '@prisma/debug@7.9.0': {} + + '@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 + + '@prisma/dev@0.24.14(typescript@7.0.2)': + 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@7.0.2) + zeptomatch: 2.1.0 + transitivePeerDependencies: + - typescript + optional: true + + '@prisma/driver-adapter-utils@7.9.0': + dependencies: + '@prisma/debug': 7.9.0 + + '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': {} + + '@prisma/engines-version@7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad': {} + + '@prisma/engines@6.19.3': + dependencies: + '@prisma/debug': 6.19.3 + '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 + '@prisma/fetch-engine': 6.19.3 + '@prisma/get-platform': 6.19.3 + + '@prisma/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 + + '@prisma/fetch-engine@6.19.3': + dependencies: + '@prisma/debug': 6.19.3 + '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 + '@prisma/get-platform': 6.19.3 + + '@prisma/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 + + '@prisma/get-platform@6.19.3': + dependencies: + '@prisma/debug': 6.19.3 + + '@prisma/get-platform@7.2.0': + dependencies: + '@prisma/debug': 7.2.0 + + '@prisma/get-platform@7.9.0': + dependencies: + '@prisma/debug': 7.9.0 + + '@prisma/query-plan-executor@7.2.0': {} + + '@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 + + '@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' '@protobufjs/aspromise@1.1.2': {} @@ -22263,7 +23398,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 +23411,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 +23424,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.3.16)(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.3.16)(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: + - '@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-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: @@ -22426,9 +23597,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 +23639,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 +23765,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 +23780,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 +23851,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 +23860,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 +23874,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 +23896,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 +23918,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 +23975,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 +23990,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 +24006,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 +24114,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 +24190,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 +24258,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 +24271,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 +24352,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 +24382,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 +24459,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 +24477,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 +24498,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 +24693,54 @@ snapshots: '@types/cookie@0.6.0': {} + '@types/d3-array@3.0.3': {} + '@types/d3-array@3.2.2': {} + '@types/d3-color@3.1.0': {} + '@types/d3-color@3.1.3': {} + '@types/d3-delaunay@6.0.1': {} + '@types/d3-ease@3.0.2': {} + '@types/d3-format@3.0.1': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-interpolate@3.0.1': + dependencies: + '@types/d3-color': 3.1.3 + '@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 + '@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 + '@types/d3-shape@3.1.8': dependencies: '@types/d3-path': 3.1.1 + '@types/d3-time-format@2.1.0': {} + + '@types/d3-time@3.0.0': {} + '@types/d3-time@3.0.4': {} '@types/d3-timer@3.0.2': {} @@ -23583,6 +24772,8 @@ snapshots: '@types/estree@1.0.9': {} + '@types/geojson@7946.0.16': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -23948,6 +25139,83 @@ snapshots: untun: 0.1.3 uqr: 0.1.2 + '@visx/curve@4.0.1-alpha.0': + dependencies: + '@visx/vendor': 4.0.0-alpha.0 + + '@visx/event@4.0.1-alpha.0': + dependencies: + '@types/react': 19.2.7 + '@visx/point': 4.0.1-alpha.0 + + '@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 + + '@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 + + '@visx/point@4.0.1-alpha.0': {} + + '@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 + + '@visx/scale@4.0.1-alpha.0': + dependencies: + '@visx/vendor': 4.0.0-alpha.0 + + '@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 + + '@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 + '@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 +25721,8 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + aws-ssl-profiles@1.1.2: {} + aws4fetch@1.0.20: {} axios@1.16.1(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0): @@ -24729,6 +25999,13 @@ snapshots: dependencies: is-windows: 1.0.2 + better-result@2.10.0: {} + + better-sqlite3@12.11.1: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 @@ -24860,12 +26137,27 @@ snapshots: bytes@3.1.2: {} + c12@3.1.0: + dependencies: + chokidar: 4.0.3 + confbox: 0.2.2 + defu: 6.1.4 + dotenv: 16.6.1 + exsolve: 1.0.8 + giget: 2.0.0 + jiti: 2.7.0 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 1.0.0 + pkg-types: 2.3.0 + rc9: 2.1.2 + c12@3.3.3(magicast@0.5.2): dependencies: 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 +26169,23 @@ 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 + cac@6.7.14: {} call-bind-apply-helpers@1.0.2: @@ -25021,6 +26330,8 @@ snapshots: dependencies: clsx: 2.1.1 + classnames@2.5.1: {} + cli-boxes@3.0.0: {} cli-cursor@2.1.0: @@ -25161,6 +26472,8 @@ snapshots: confbox@0.2.2: {} + confbox@0.2.4: {} + config-chain@1.1.13: dependencies: ini: 1.3.8 @@ -25287,16 +26600,30 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.1: + dependencies: + internmap: 2.0.3 + 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 + d3-ease@3.0.1: {} + d3-format@3.1.0: {} + d3-format@3.1.2: {} + d3-geo@3.1.0: + dependencies: + d3-array: 3.2.4 + d3-interpolate@3.0.1: dependencies: d3-color: 3.1.0 @@ -25347,7 +26674,19 @@ snapshots: dayjs@1.11.19: {} - db0@0.3.4: {} + db0@0.3.4(@electric-sql/pglite@0.3.16)(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.3.16 + 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 +26712,10 @@ snapshots: dependencies: character-entities: 2.0.2 + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + dedent-js@1.0.1: {} deep-equal@2.2.3: @@ -25396,8 +26739,12 @@ snapshots: which-collection: 1.0.2 which-typed-array: 1.1.19 + deep-extend@0.6.0: {} + deep-is@0.1.4: {} + deepmerge-ts@7.1.5: {} + deepmerge@4.3.1: {} defaults@1.0.4: @@ -25420,12 +26767,18 @@ snapshots: defu@6.1.4: {} + defu@6.1.7: {} + 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 + delayed-stream@1.0.0: {} denque@2.1.0: {} @@ -25524,8 +26877,51 @@ snapshots: dotenv@17.2.3: {} + dotenv@17.4.2: {} + dotenv@8.6.0: {} + drizzle-kit@0.31.10: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + tsx: 4.21.0 + + drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.3.16)(@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@7.0.2))(typescript@7.0.2))(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@7.0.2)): + optionalDependencies: + '@cloudflare/workers-types': 4.20260317.1 + '@electric-sql/pglite': 0.3.16 + '@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@7.0.2))(typescript@7.0.2) + 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@7.0.2) + + 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-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) + + 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 +26949,22 @@ snapshots: ee-first@1.1.1: {} + effect@3.20.0: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + + effect@3.21.0: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + ejs@5.0.1: {} electron-to-chromium@1.5.267: {} + elkjs@0.11.1: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -25598,6 +27006,8 @@ snapshots: env-paths@2.2.1: {} + env-paths@3.0.0: {} + env-runner@0.1.14(miniflare@4.20260617.1)(wrangler@4.103.0): dependencies: crossws: 0.4.6(srvx@0.11.17) @@ -25666,6 +27076,31 @@ snapshots: transitivePeerDependencies: - supports-color + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + esbuild@0.20.2: optionalDependencies: '@esbuild/aix-ppc64': 0.20.2 @@ -25968,6 +27403,8 @@ snapshots: signal-exit: 4.1.0 strip-final-newline: 3.0.0 + expand-template@2.0.3: {} + expand-tilde@2.0.2: dependencies: homedir-polyfill: 1.0.3 @@ -26213,6 +27650,12 @@ snapshots: transitivePeerDependencies: - supports-color + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + + fast-decode-uri-component@1.0.1: {} + fast-deep-equal@3.1.3: {} fast-equals@5.4.0: {} @@ -26231,6 +27674,10 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + fast-sha256@1.3.0: {} fast-uri@3.1.2: {} @@ -26332,6 +27779,12 @@ 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 + find-up-simple@1.0.1: {} find-up@3.0.0: @@ -26468,6 +27921,10 @@ snapshots: transitivePeerDependencies: - supports-color + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + generic-pool@3.9.0: {} gensync@1.0.0-beta.2: {} @@ -26531,6 +27988,10 @@ snapshots: nypm: 0.6.2 pathe: 2.0.3 + giget@3.3.0: {} + + github-from-package@0.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -26600,6 +28061,10 @@ snapshots: graceful-fs@4.2.11: {} + grammex@3.1.13: {} + + graphmatch@1.1.1: {} + gtoken@8.0.0: dependencies: gaxios: 7.1.3 @@ -27088,6 +28553,8 @@ snapshots: is-promise@4.0.0: {} + is-property@1.0.2: {} + is-reference@1.2.1: dependencies: '@types/estree': 1.0.9 @@ -27742,6 +29209,8 @@ snapshots: lru-cache@7.18.3: {} + lru.min@1.1.4: {} + lucide-react@0.561.0(react@19.2.3): dependencies: react: 19.2.3 @@ -28398,6 +29867,8 @@ snapshots: mimic-function@5.0.1: {} + mimic-response@3.1.0: {} + miniflare@4.20260609.0: dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -28513,17 +29984,35 @@ 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 + 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 + nan@2.27.0: optional: true nanoid@3.3.12: {} + napi-build-utils@2.0.0: {} + natural-compare@1.4.0: {} needle@3.5.0: @@ -28574,11 +30063,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 +30078,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 +30117,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 +30132,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 +30171,163 @@ snapshots: - uploadthing - wrangler - nitropack@2.13.1(aws4fetch@1.0.20)(rolldown@1.1.5): + 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.4.2)(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@3.3.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(@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 + nf3: 0.3.17 + ocache: 0.1.5 + ofetch: 2.0.0-alpha.3 + ohash: 2.0.11 + 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(@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.4.2 + giget: 3.3.0 + jiti: 2.7.0 + 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) + 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' + - '@netlify/runtime' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - better-sqlite3 + - chokidar + - drizzle-orm + - idb-keyval + - ioredis + - lru-cache + - miniflare + - mongodb + - mysql2 + - sqlite3 + - uploadthing + - wrangler + + nitropack@2.13.1(@electric-sql/pglite@0.3.16)(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) + '@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.3.16)(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 + 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.3.16)(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 + 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-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) @@ -28703,7 +30348,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(@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 @@ -28749,7 +30394,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-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 @@ -28784,6 +30429,10 @@ snapshots: - supports-color - uploadthing + node-abi@3.94.0: + dependencies: + semver: 7.8.4 + node-addon-api@6.1.0: optional: true @@ -29415,8 +31064,12 @@ snapshots: pend@1.2.0: {} + perfect-debounce@1.0.0: {} + perfect-debounce@2.0.0: {} + perfect-debounce@2.1.0: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -29502,10 +31155,27 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres@3.4.7: {} + 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 + prelude-ls@1.2.1: {} premove@4.0.0: {} @@ -29528,6 +31198,52 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + prisma@6.19.3(typescript@7.0.2): + dependencies: + '@prisma/config': 6.19.3 + '@prisma/engines': 6.19.3 + optionalDependencies: + typescript: 7.0.2 + transitivePeerDependencies: + - magicast + + 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 + + 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@7.0.2): + dependencies: + '@prisma/config': 7.9.0(magicast@0.5.2) + '@prisma/dev': 0.24.14(typescript@7.0.2) + '@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: 7.0.2 + 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 +31267,12 @@ 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 + property-information@6.5.0: {} property-information@7.1.0: {} @@ -29661,6 +31383,8 @@ snapshots: - typescript - utf-8-validate + pure-rand@6.1.0: {} + qs@6.14.0: dependencies: side-channel: 1.1.0 @@ -29770,6 +31494,18 @@ snapshots: defu: 6.1.4 destr: 2.0.5 + rc9@3.0.1: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + react-day-picker@9.14.0(react@19.2.3): dependencies: '@date-fns/tz': 1.4.1 @@ -30139,6 +31875,8 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + remeda@2.33.4: {} + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -30183,6 +31921,10 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 + ret@0.5.0: {} + + retry@0.12.0: {} + retry@0.13.1: {} reusify@1.1.0: {} @@ -30195,6 +31937,8 @@ snapshots: robot3@0.4.1: {} + robust-predicates@3.0.3: {} + 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 +32140,10 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + safer-buffer@2.1.2: {} sass@1.101.0: @@ -30477,6 +32225,8 @@ snapshots: transitivePeerDependencies: - supports-color + seq-queue@0.0.5: {} + serialize-error@2.1.0: {} serialize-javascript@6.0.2: @@ -30664,6 +32414,14 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + simple-plist@1.3.1: dependencies: bplist-creator: 0.1.0 @@ -30771,6 +32529,8 @@ snapshots: sprintf-js@1.1.3: {} + sqlstring@2.3.3: {} + srvx@0.11.17: {} srvx@0.8.16: {} @@ -30888,6 +32648,8 @@ snapshots: strip-final-newline@3.0.0: {} + strip-json-comments@2.0.1: {} + strip-json-comments@3.1.1: {} strip-json-comments@5.0.3: {} @@ -31251,6 +33013,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + tw-animate-css@1.4.0: {} tweetnacl@0.14.5: {} @@ -31512,7 +33278,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.3.16)(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 + 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.3.16)(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@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 @@ -31524,14 +33305,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(@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@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 +33377,15 @@ snapshots: uuid@7.0.3: {} + valibot@1.2.0(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + valibot@1.2.0(typescript@7.0.2): + optionalDependencies: + typescript: 7.0.2 + optional: true + validate-npm-package-name@5.0.1: {} vary@1.1.2: {} @@ -31632,10 +33422,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 +33444,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 +33455,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 +34110,11 @@ snapshots: cookie-es: 2.0.1 youch-core: 0.3.3 + zeptomatch@2.1.0: + dependencies: + grammex: 3.1.13 + graphmatch: 1.1.1 + zimmerframe@1.1.4: {} zip-stream@6.0.1: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d0616db5d..2c22adea7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -95,3 +95,12 @@ allowBuilds: # JS fallback, so their native builds are not required. cpu-features: false ssh2: false + # @tanstack/ai-persistence-prisma runs `prisma generate` (nx `db:generate`) + # as a build/typecheck prerequisite, so the Prisma engine + client build + # scripts must run. + '@prisma/client': true + '@prisma/engines': true + prisma: true + # examples/persistent-chat-prisma uses the Prisma 7 better-sqlite3 driver + # adapter, whose native addon must build. + better-sqlite3: true 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..e1a7626c0 100644 --- a/testing/e2e/src/routes/$provider/$feature.tsx +++ b/testing/e2e/src/routes/$provider/$feature.tsx @@ -351,7 +351,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..165ffcb87 --- /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 + * `messages: false` (caches no transcript), 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 `messages: false` 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..b7a25af70 --- /dev/null +++ b/testing/e2e/src/routes/persistence-durability.tsx @@ -0,0 +1,160 @@ +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 `messages: false` (caches no transcript, only a resume pointer), + * 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 instance both modes share. `text`/`interrupt` pass it directly +// (messages:true default — transcript cached to localStorage). `server-interrupt` +// wraps it with `messages: false`, 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 ? { store, messages: false } : store, + }) + + const [input, setInput] = useState('') + + const handleSubmit = () => { + const text = input.trim() + if (!text) return + setInput('') + void sendMessage(text) + } + + return ( +
+