From 3e0e477b4574a3014037245d04496d9397d9fa65 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 22 Jul 2026 18:06:06 +0200 Subject: [PATCH 01/26] feat(persistence): add ai-persistence + drizzle/prisma/cloudflare backends Server-side persistence for chat(): durable thread messages, run records, and interrupts via the withChatPersistence middleware, with pluggable backends. - @tanstack/ai-persistence: store contracts, withChatPersistence / withGenerationPersistence middleware, memoryPersistence reference store, conformance testkit. Locks (LockStore/InMemoryLockStore/LocksCapability) live here rather than core; the sandbox-consumer bridge is deferred. - -drizzle / -prisma / -cloudflare: backend store implementations + migration / schema / models CLIs. Cloudflare D1 delegates to the drizzle backend. Reconciled against the shipped ephemeral-interrupt engine: the middleware records interrupts and gates new input, and delegates resume-tool-state reconstruction to the engine (resume batch + interrupt bindings in history). Claude-Session: https://claude.ai/code/session_01RqjWdHxvmMrhjbd8dYvENp --- .changeset/persistence-packages.md | 17 + .../bin/tanstack-ai-cloudflare-migrations.mjs | 2 + .../migrations/0000_tanstack_ai_initial.sql | 32 + .../ai-persistence-cloudflare/package.json | 64 ++ .../ai-persistence-cloudflare/src/assets.d.ts | 4 + .../src/assets/0000_tanstack_ai_initial.sql | 32 + .../ai-persistence-cloudflare/src/bindings.ts | 24 + packages/ai-persistence-cloudflare/src/cli.ts | 4 + packages/ai-persistence-cloudflare/src/d1.ts | 13 + .../ai-persistence-cloudflare/src/index.ts | 82 ++ .../ai-persistence-cloudflare/src/locks.ts | 376 ++++++++ .../src/migration-cli.ts | 125 +++ .../src/migrations.ts | 25 + .../tests/api-types.test-d.ts | 65 ++ .../tests/locks.test.ts | 359 ++++++++ .../tests/migration-cli.test.ts | 65 ++ .../tests/migrations.test.ts | 53 ++ .../tests/package-contract.test.ts | 46 + .../tests/runtime.conformance.test.ts | 77 ++ .../ai-persistence-cloudflare/tsconfig.json | 9 + .../ai-persistence-cloudflare/vite.config.ts | 40 + .../bin/tanstack-ai-drizzle-migrations.mjs | 2 + .../bin/tanstack-ai-drizzle-schema.mjs | 2 + .../ai-persistence-drizzle/drizzle.config.ts | 19 + .../drizzle/0000_tanstack_ai_initial.sql | 32 + .../drizzle/meta/0000_snapshot.json | 203 ++++ .../drizzle/meta/_journal.json | 13 + packages/ai-persistence-drizzle/package.json | 66 ++ .../ai-persistence-drizzle/src/assets.d.ts | 9 + .../src/assets/0000_tanstack_ai_initial.sql | 32 + .../src/assets/tanstack-ai-schema.ts | 73 ++ packages/ai-persistence-drizzle/src/cli.ts | 10 + packages/ai-persistence-drizzle/src/index.ts | 77 ++ .../src/migration-cli.ts | 125 +++ .../ai-persistence-drizzle/src/migrations.ts | 20 + .../src/schema-cli-main.ts | 10 + .../ai-persistence-drizzle/src/schema-cli.ts | 124 +++ .../src/schema-contract.ts | 137 +++ .../src/schema-source.ts | 12 + packages/ai-persistence-drizzle/src/schema.ts | 78 ++ .../src/sqlite-migrations.ts | 68 ++ packages/ai-persistence-drizzle/src/sqlite.ts | 82 ++ packages/ai-persistence-drizzle/src/stores.ts | 274 ++++++ .../tests/api-types.test-d.ts | 39 + .../tests/custom-schema.test.ts | 113 +++ .../tests/drizzle.conformance.test.ts | 9 + .../tests/migration-cli.test.ts | 65 ++ .../tests/migrations.test.ts | 101 ++ .../tests/package-contract.test.ts | 67 ++ .../tests/schema-cli.test.ts | 67 ++ .../tests/schema-source.test.ts | 31 + .../tests/sqlite.test.ts | 53 ++ .../tests/store-behavior.test.ts | 40 + .../tests/variant-schema.ts | 93 ++ packages/ai-persistence-drizzle/tsconfig.json | 8 + .../ai-persistence-drizzle/vite.config.ts | 42 + packages/ai-persistence-prisma/.gitignore | 3 + .../bin/tanstack-ai-prisma-models.mjs | 2 + packages/ai-persistence-prisma/package.json | 91 ++ .../prisma/schema.prisma | 10 + .../prisma/tanstack-ai.prisma | 50 + .../ai-persistence-prisma/src/assets.d.ts | 4 + .../src/assets/tanstack-ai.prisma | 50 + packages/ai-persistence-prisma/src/cli.ts | 10 + packages/ai-persistence-prisma/src/index.ts | 57 ++ .../src/model-contract.ts | 217 +++++ .../ai-persistence-prisma/src/models-cli.ts | 121 +++ packages/ai-persistence-prisma/src/models.ts | 7 + packages/ai-persistence-prisma/src/stores.ts | 237 +++++ .../tests/api-types.test-d.ts | 38 + .../tests/model-mapping.test.ts | 137 +++ .../tests/models-cli.test.ts | 59 ++ .../tests/models.test.ts | 66 ++ .../tests/package-contract.test.ts | 28 + .../tests/prisma.conformance.test.ts | 82 ++ .../tests/store-behavior.test.ts | 72 ++ packages/ai-persistence-prisma/tsconfig.json | 8 + packages/ai-persistence-prisma/vite.config.ts | 37 + packages/ai-persistence/package.json | 63 ++ packages/ai-persistence/src/capabilities.ts | 24 + packages/ai-persistence/src/index.ts | 43 + packages/ai-persistence/src/interrupts.ts | 24 + packages/ai-persistence/src/locks.ts | 60 ++ packages/ai-persistence/src/memory.ts | 165 ++++ packages/ai-persistence/src/middleware.ts | 434 +++++++++ .../ai-persistence/src/testkit/conformance.ts | 379 ++++++++ packages/ai-persistence/src/types.ts | 386 ++++++++ .../ai-persistence/tests/capabilities.test.ts | 146 +++ .../ai-persistence/tests/error-abort.test.ts | 285 ++++++ .../ai-persistence/tests/interrupts.test.ts | 867 ++++++++++++++++++ .../tests/memory.conformance.test.ts | 4 + packages/ai-persistence/tests/memory.test.ts | 132 +++ .../tests/persistence-composition.test.ts | 155 ++++ .../tests/persistence-fixtures.ts | 64 ++ .../tests/persistence-types.test-d.ts | 142 +++ .../tests/persistence-validation.test.ts | 41 + .../ai-persistence/tests/state-only.test.ts | 97 ++ .../tests/with-persistence.test.ts | 268 ++++++ packages/ai-persistence/tsconfig.json | 8 + packages/ai-persistence/vite.config.ts | 41 + pnpm-lock.yaml | 861 ++++++++++++++++- pnpm-workspace.yaml | 6 + 102 files changed, 9746 insertions(+), 45 deletions(-) create mode 100644 .changeset/persistence-packages.md create mode 100644 packages/ai-persistence-cloudflare/bin/tanstack-ai-cloudflare-migrations.mjs create mode 100644 packages/ai-persistence-cloudflare/migrations/0000_tanstack_ai_initial.sql create mode 100644 packages/ai-persistence-cloudflare/package.json create mode 100644 packages/ai-persistence-cloudflare/src/assets.d.ts create mode 100644 packages/ai-persistence-cloudflare/src/assets/0000_tanstack_ai_initial.sql create mode 100644 packages/ai-persistence-cloudflare/src/bindings.ts create mode 100644 packages/ai-persistence-cloudflare/src/cli.ts create mode 100644 packages/ai-persistence-cloudflare/src/d1.ts create mode 100644 packages/ai-persistence-cloudflare/src/index.ts create mode 100644 packages/ai-persistence-cloudflare/src/locks.ts create mode 100644 packages/ai-persistence-cloudflare/src/migration-cli.ts create mode 100644 packages/ai-persistence-cloudflare/src/migrations.ts create mode 100644 packages/ai-persistence-cloudflare/tests/api-types.test-d.ts create mode 100644 packages/ai-persistence-cloudflare/tests/locks.test.ts create mode 100644 packages/ai-persistence-cloudflare/tests/migration-cli.test.ts create mode 100644 packages/ai-persistence-cloudflare/tests/migrations.test.ts create mode 100644 packages/ai-persistence-cloudflare/tests/package-contract.test.ts create mode 100644 packages/ai-persistence-cloudflare/tests/runtime.conformance.test.ts create mode 100644 packages/ai-persistence-cloudflare/tsconfig.json create mode 100644 packages/ai-persistence-cloudflare/vite.config.ts create mode 100755 packages/ai-persistence-drizzle/bin/tanstack-ai-drizzle-migrations.mjs create mode 100755 packages/ai-persistence-drizzle/bin/tanstack-ai-drizzle-schema.mjs create mode 100644 packages/ai-persistence-drizzle/drizzle.config.ts create mode 100644 packages/ai-persistence-drizzle/drizzle/0000_tanstack_ai_initial.sql create mode 100644 packages/ai-persistence-drizzle/drizzle/meta/0000_snapshot.json create mode 100644 packages/ai-persistence-drizzle/drizzle/meta/_journal.json create mode 100644 packages/ai-persistence-drizzle/package.json create mode 100644 packages/ai-persistence-drizzle/src/assets.d.ts create mode 100644 packages/ai-persistence-drizzle/src/assets/0000_tanstack_ai_initial.sql create mode 100644 packages/ai-persistence-drizzle/src/assets/tanstack-ai-schema.ts create mode 100644 packages/ai-persistence-drizzle/src/cli.ts create mode 100644 packages/ai-persistence-drizzle/src/index.ts create mode 100644 packages/ai-persistence-drizzle/src/migration-cli.ts create mode 100644 packages/ai-persistence-drizzle/src/migrations.ts create mode 100644 packages/ai-persistence-drizzle/src/schema-cli-main.ts create mode 100644 packages/ai-persistence-drizzle/src/schema-cli.ts create mode 100644 packages/ai-persistence-drizzle/src/schema-contract.ts create mode 100644 packages/ai-persistence-drizzle/src/schema-source.ts create mode 100644 packages/ai-persistence-drizzle/src/schema.ts create mode 100644 packages/ai-persistence-drizzle/src/sqlite-migrations.ts create mode 100644 packages/ai-persistence-drizzle/src/sqlite.ts create mode 100644 packages/ai-persistence-drizzle/src/stores.ts create mode 100644 packages/ai-persistence-drizzle/tests/api-types.test-d.ts create mode 100644 packages/ai-persistence-drizzle/tests/custom-schema.test.ts create mode 100644 packages/ai-persistence-drizzle/tests/drizzle.conformance.test.ts create mode 100644 packages/ai-persistence-drizzle/tests/migration-cli.test.ts create mode 100644 packages/ai-persistence-drizzle/tests/migrations.test.ts create mode 100644 packages/ai-persistence-drizzle/tests/package-contract.test.ts create mode 100644 packages/ai-persistence-drizzle/tests/schema-cli.test.ts create mode 100644 packages/ai-persistence-drizzle/tests/schema-source.test.ts create mode 100644 packages/ai-persistence-drizzle/tests/sqlite.test.ts create mode 100644 packages/ai-persistence-drizzle/tests/store-behavior.test.ts create mode 100644 packages/ai-persistence-drizzle/tests/variant-schema.ts create mode 100644 packages/ai-persistence-drizzle/tsconfig.json create mode 100644 packages/ai-persistence-drizzle/vite.config.ts create mode 100644 packages/ai-persistence-prisma/.gitignore create mode 100644 packages/ai-persistence-prisma/bin/tanstack-ai-prisma-models.mjs create mode 100644 packages/ai-persistence-prisma/package.json create mode 100644 packages/ai-persistence-prisma/prisma/schema.prisma create mode 100644 packages/ai-persistence-prisma/prisma/tanstack-ai.prisma create mode 100644 packages/ai-persistence-prisma/src/assets.d.ts create mode 100644 packages/ai-persistence-prisma/src/assets/tanstack-ai.prisma create mode 100644 packages/ai-persistence-prisma/src/cli.ts create mode 100644 packages/ai-persistence-prisma/src/index.ts create mode 100644 packages/ai-persistence-prisma/src/model-contract.ts create mode 100644 packages/ai-persistence-prisma/src/models-cli.ts create mode 100644 packages/ai-persistence-prisma/src/models.ts create mode 100644 packages/ai-persistence-prisma/src/stores.ts create mode 100644 packages/ai-persistence-prisma/tests/api-types.test-d.ts create mode 100644 packages/ai-persistence-prisma/tests/model-mapping.test.ts create mode 100644 packages/ai-persistence-prisma/tests/models-cli.test.ts create mode 100644 packages/ai-persistence-prisma/tests/models.test.ts create mode 100644 packages/ai-persistence-prisma/tests/package-contract.test.ts create mode 100644 packages/ai-persistence-prisma/tests/prisma.conformance.test.ts create mode 100644 packages/ai-persistence-prisma/tests/store-behavior.test.ts create mode 100644 packages/ai-persistence-prisma/tsconfig.json create mode 100644 packages/ai-persistence-prisma/vite.config.ts create mode 100644 packages/ai-persistence/package.json create mode 100644 packages/ai-persistence/src/capabilities.ts create mode 100644 packages/ai-persistence/src/index.ts create mode 100644 packages/ai-persistence/src/interrupts.ts create mode 100644 packages/ai-persistence/src/locks.ts create mode 100644 packages/ai-persistence/src/memory.ts create mode 100644 packages/ai-persistence/src/middleware.ts create mode 100644 packages/ai-persistence/src/testkit/conformance.ts create mode 100644 packages/ai-persistence/src/types.ts create mode 100644 packages/ai-persistence/tests/capabilities.test.ts create mode 100644 packages/ai-persistence/tests/error-abort.test.ts create mode 100644 packages/ai-persistence/tests/interrupts.test.ts create mode 100644 packages/ai-persistence/tests/memory.conformance.test.ts create mode 100644 packages/ai-persistence/tests/memory.test.ts create mode 100644 packages/ai-persistence/tests/persistence-composition.test.ts create mode 100644 packages/ai-persistence/tests/persistence-fixtures.ts create mode 100644 packages/ai-persistence/tests/persistence-types.test-d.ts create mode 100644 packages/ai-persistence/tests/persistence-validation.test.ts create mode 100644 packages/ai-persistence/tests/state-only.test.ts create mode 100644 packages/ai-persistence/tests/with-persistence.test.ts create mode 100644 packages/ai-persistence/tsconfig.json create mode 100644 packages/ai-persistence/vite.config.ts diff --git a/.changeset/persistence-packages.md b/.changeset/persistence-packages.md new file mode 100644 index 000000000..f5ad1a717 --- /dev/null +++ b/.changeset/persistence-packages.md @@ -0,0 +1,17 @@ +--- +'@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. + +`withChatPersistence(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 `withChatPersistence` / `withGenerationPersistence` middleware, and an in-memory reference store (`memoryPersistence`) plus a conformance testkit. +- `@tanstack/ai-persistence-drizzle` — Drizzle-backed stores (SQLite via `node:sqlite`, plus a bring-your-own-schema contract) with migration and schema CLIs. +- `@tanstack/ai-persistence-prisma` — Prisma-backed stores with a models CLI that emits a provider-neutral schema fragment. +- `@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. diff --git a/packages/ai-persistence-cloudflare/bin/tanstack-ai-cloudflare-migrations.mjs b/packages/ai-persistence-cloudflare/bin/tanstack-ai-cloudflare-migrations.mjs new file mode 100644 index 000000000..0a4de6588 --- /dev/null +++ b/packages/ai-persistence-cloudflare/bin/tanstack-ai-cloudflare-migrations.mjs @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import '../dist/esm/cli.js' diff --git a/packages/ai-persistence-cloudflare/migrations/0000_tanstack_ai_initial.sql b/packages/ai-persistence-cloudflare/migrations/0000_tanstack_ai_initial.sql new file mode 100644 index 000000000..3e5570ec1 --- /dev/null +++ b/packages/ai-persistence-cloudflare/migrations/0000_tanstack_ai_initial.sql @@ -0,0 +1,32 @@ +CREATE TABLE `interrupts` ( + `interrupt_id` text PRIMARY KEY NOT NULL, + `run_id` text NOT NULL, + `thread_id` text NOT NULL, + `status` text NOT NULL, + `requested_at` integer NOT NULL, + `resolved_at` integer, + `payload_json` text NOT NULL, + `response_json` text +); +--> statement-breakpoint +CREATE TABLE `messages` ( + `thread_id` text PRIMARY KEY NOT NULL, + `messages_json` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `metadata` ( + `scope` text NOT NULL, + `key` text NOT NULL, + `value_json` text NOT NULL, + PRIMARY KEY(`scope`, `key`) +); +--> statement-breakpoint +CREATE TABLE `runs` ( + `run_id` text PRIMARY KEY NOT NULL, + `thread_id` text NOT NULL, + `status` text NOT NULL, + `started_at` integer NOT NULL, + `finished_at` integer, + `error` text, + `usage_json` text +); diff --git a/packages/ai-persistence-cloudflare/package.json b/packages/ai-persistence-cloudflare/package.json new file mode 100644 index 000000000..bdd3237c0 --- /dev/null +++ b/packages/ai-persistence-cloudflare/package.json @@ -0,0 +1,64 @@ +{ + "name": "@tanstack/ai-persistence-cloudflare", + "version": "0.0.0", + "description": "Cloudflare D1 and Durable Object state persistence for TanStack AI.", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-persistence-cloudflare" + }, + "keywords": [ + "ai", + "tanstack", + "persistence", + "cloudflare", + "d1", + "durable-objects" + ], + "type": "module", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + } + }, + "bin": { + "tanstack-ai-cloudflare-migrations": "./bin/tanstack-ai-cloudflare-migrations.mjs" + }, + "files": [ + "bin", + "dist", + "migrations", + "src" + ], + "scripts": { + "build": "vite build", + "clean": "premove ./build ./dist", + "lint:fix": "oxlint src --type-aware --fix", + "test:build": "publint --strict", + "test:lib": "vitest", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc", + "test:oxlint": "oxlint src --type-aware" + }, + "peerDependencies": { + "@cloudflare/workers-types": ">=4.20260317.1", + "@tanstack/ai": "workspace:^", + "@tanstack/ai-persistence": "workspace:^", + "@tanstack/ai-persistence-drizzle": "workspace:^", + "drizzle-orm": ">=0.44.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@tanstack/ai": "workspace:*", + "@tanstack/ai-persistence": "workspace:*", + "@tanstack/ai-persistence-drizzle": "workspace:*", + "@vitest/coverage-v8": "4.0.14", + "drizzle-orm": "^0.45.0", + "miniflare": "^4.20260609.0" + } +} diff --git a/packages/ai-persistence-cloudflare/src/assets.d.ts b/packages/ai-persistence-cloudflare/src/assets.d.ts new file mode 100644 index 000000000..b04692701 --- /dev/null +++ b/packages/ai-persistence-cloudflare/src/assets.d.ts @@ -0,0 +1,4 @@ +declare module '*.sql?raw' { + const contents: string + export default contents +} diff --git a/packages/ai-persistence-cloudflare/src/assets/0000_tanstack_ai_initial.sql b/packages/ai-persistence-cloudflare/src/assets/0000_tanstack_ai_initial.sql new file mode 100644 index 000000000..3e5570ec1 --- /dev/null +++ b/packages/ai-persistence-cloudflare/src/assets/0000_tanstack_ai_initial.sql @@ -0,0 +1,32 @@ +CREATE TABLE `interrupts` ( + `interrupt_id` text PRIMARY KEY NOT NULL, + `run_id` text NOT NULL, + `thread_id` text NOT NULL, + `status` text NOT NULL, + `requested_at` integer NOT NULL, + `resolved_at` integer, + `payload_json` text NOT NULL, + `response_json` text +); +--> statement-breakpoint +CREATE TABLE `messages` ( + `thread_id` text PRIMARY KEY NOT NULL, + `messages_json` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `metadata` ( + `scope` text NOT NULL, + `key` text NOT NULL, + `value_json` text NOT NULL, + PRIMARY KEY(`scope`, `key`) +); +--> statement-breakpoint +CREATE TABLE `runs` ( + `run_id` text PRIMARY KEY NOT NULL, + `thread_id` text NOT NULL, + `status` text NOT NULL, + `started_at` integer NOT NULL, + `finished_at` integer, + `error` text, + `usage_json` text +); diff --git a/packages/ai-persistence-cloudflare/src/bindings.ts b/packages/ai-persistence-cloudflare/src/bindings.ts new file mode 100644 index 000000000..cdf01afac --- /dev/null +++ b/packages/ai-persistence-cloudflare/src/bindings.ts @@ -0,0 +1,24 @@ +export interface DurableObjectStubBinding { + fetch: ( + input: Request | string | URL, + init?: RequestInit, + ) => Promise +} + +/** The Durable Object namespace surface used by the lock client. */ +export interface DurableObjectNamespaceBinding { + idFromName: (name: string) => TId + get: (id: TId) => DurableObjectStubBinding +} + +export interface LockDurableObjectStorage { + get: (key: string) => Promise + put: (key: string, value: unknown) => Promise + delete: (key: string) => Promise + setAlarm: (timestamp: number) => Promise + deleteAlarm: () => Promise +} + +export interface LockDurableObjectState { + storage: LockDurableObjectStorage +} diff --git a/packages/ai-persistence-cloudflare/src/cli.ts b/packages/ai-persistence-cloudflare/src/cli.ts new file mode 100644 index 000000000..6d17f2681 --- /dev/null +++ b/packages/ai-persistence-cloudflare/src/cli.ts @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { runCloudflareMigrationsCli } from './migration-cli' + +await runCloudflareMigrationsCli(process.argv.slice(2)) diff --git a/packages/ai-persistence-cloudflare/src/d1.ts b/packages/ai-persistence-cloudflare/src/d1.ts new file mode 100644 index 000000000..011e70ce4 --- /dev/null +++ b/packages/ai-persistence-cloudflare/src/d1.ts @@ -0,0 +1,13 @@ +import { drizzle } from 'drizzle-orm/d1' +import { drizzlePersistence, schema } from '@tanstack/ai-persistence-drizzle' + +/** Create the structured stores owned by a migrated Cloudflare D1 binding. */ +export function createD1Stores(d1: D1Database) { + const persistence = drizzlePersistence(drizzle(d1, { schema })) + return { + messages: persistence.stores.messages, + runs: persistence.stores.runs, + interrupts: persistence.stores.interrupts, + metadata: persistence.stores.metadata, + } +} diff --git a/packages/ai-persistence-cloudflare/src/index.ts b/packages/ai-persistence-cloudflare/src/index.ts new file mode 100644 index 000000000..f1661ac18 --- /dev/null +++ b/packages/ai-persistence-cloudflare/src/index.ts @@ -0,0 +1,82 @@ +import { createD1Stores } from './d1' +import { createDurableObjectLockStore } from './locks' +import type { + AIPersistence, + AIPersistenceStores, + InterruptStore, + MessageStore, + MetadataStore, + RunStore, +} from '@tanstack/ai-persistence' +import type { LockStore } from '@tanstack/ai-persistence' +import type { DurableObjectLockStoreOptions } from './locks' + +export { createD1Stores } from './d1' +export { + CloudflareLockDurableObject, + createDurableObjectLockStore, +} from './locks' +export { d1Migrations } from './migrations' +export type { D1Migration } from './migrations' +export type { DurableObjectLockStoreOptions } from './locks' +export type { + DurableObjectNamespaceBinding, + DurableObjectStubBinding, + LockDurableObjectState, + LockDurableObjectStorage, +} from './bindings' + +export interface CloudflarePersistenceOptions { + d1?: D1Database + durableObjects?: DurableObjectNamespace + lockOptions?: DurableObjectLockStoreOptions +} + +interface D1Stores { + messages: MessageStore + runs: RunStore + interrupts: InterruptStore + metadata: MetadataStore +} + +interface DurableObjectStores { + locks: LockStore +} + +type BindingStores< + TOptions, + TKey extends PropertyKey, + TStores, +> = TKey extends keyof TOptions + ? undefined extends TOptions[TKey] + ? Partial + : TStores + : {} + +type Simplify = { [TKey in keyof T]: T[TKey] } + +export type CloudflarePersistenceStores = Simplify< + BindingStores & + BindingStores +> + +/** + * Compose only the stores backed by the supplied Cloudflare bindings. + * Binding keys remain exact in the return type, including optional bindings. + */ +export function cloudflarePersistence< + const TOptions extends CloudflarePersistenceOptions, +>(options: TOptions): AIPersistence> +export function cloudflarePersistence( + options: CloudflarePersistenceOptions, +): AIPersistence { + const stores: AIPersistenceStores = {} + if (options.d1) Object.assign(stores, createD1Stores(options.d1)) + if (options.durableObjects) { + stores.locks = createDurableObjectLockStore( + options.durableObjects, + options.lockOptions, + ) + } + return { stores } +} diff --git a/packages/ai-persistence-cloudflare/src/locks.ts b/packages/ai-persistence-cloudflare/src/locks.ts new file mode 100644 index 000000000..aefbf3a8c --- /dev/null +++ b/packages/ai-persistence-cloudflare/src/locks.ts @@ -0,0 +1,376 @@ +import type { LockStore } from '@tanstack/ai-persistence' +import type { + DurableObjectNamespaceBinding, + DurableObjectStubBinding, + LockDurableObjectState, +} from './bindings' + +interface LeaseRecord { + ownerId: string + expiresAt: number +} + +interface LeaseRequest { + ownerId: string + leaseDurationMs: number +} + +export interface DurableObjectLockStoreOptions { + /** Duration of each lock lease. Defaults to 30 seconds. */ + leaseDurationMs?: number + /** Renewal cadence. Defaults to one third of the lease duration. */ + renewIntervalMs?: number + /** Maximum time spent retrying a contended acquire. Defaults to 30 seconds. */ + acquireTimeoutMs?: number + /** Delay between contended acquire attempts. Defaults to 50 milliseconds. */ + retryDelayMs?: number + /** Override owner ID generation, primarily for deterministic runtimes/tests. */ + createOwnerId?: () => string +} + +interface ResolvedLockOptions { + leaseDurationMs: number + renewIntervalMs: number + acquireTimeoutMs: number + retryDelayMs: number + createOwnerId: () => string +} + +type SettledValue = + | { status: 'fulfilled'; value: T } + | { status: 'rejected'; reason: unknown } + +const leaseKey = 'lease' + +function isLeaseRecord(value: unknown): value is LeaseRecord { + return ( + value !== null && + typeof value === 'object' && + 'ownerId' in value && + typeof value.ownerId === 'string' && + 'expiresAt' in value && + typeof value.expiresAt === 'number' + ) +} + +function isLeaseRequest(value: unknown): value is LeaseRequest { + return ( + value !== null && + typeof value === 'object' && + 'ownerId' in value && + typeof value.ownerId === 'string' && + value.ownerId.length > 0 && + 'leaseDurationMs' in value && + typeof value.leaseDurationMs === 'number' && + Number.isFinite(value.leaseDurationMs) && + value.leaseDurationMs > 0 + ) +} + +function response(status: number): Response { + return new Response(null, { status }) +} + +/** + * Durable Object class backing distributed lock leases. + * + * Bind this class in Wrangler and pass the resulting namespace to + * `cloudflarePersistence({ durableObjects })`. + * + * SECURITY: this DO must ONLY be reachable through its namespace binding (via + * `createDurableObjectLockStore`). Its `fetch` handler trusts the caller's + * `ownerId` and performs no authentication, so routing public HTTP straight to + * this class exposes an unauthenticated lock-manipulation surface — anyone + * could acquire, renew, or release any lock key. Never wire it into a public + * Worker route; reach it only by `namespace.get(namespace.idFromName(key))`. + */ +export class CloudflareLockDurableObject { + private operationChain: Promise = Promise.resolve() + + constructor(private readonly state: LockDurableObjectState) {} + + fetch(request: Request): Promise { + return this.serialize(() => this.handleRequest(request)) + } + + alarm(): Promise { + return this.serialize(async () => { + const lease = await this.readLease() + if (!lease) { + await this.state.storage.deleteAlarm() + return + } + if (lease.expiresAt > Date.now()) { + await this.state.storage.setAlarm(lease.expiresAt) + return + } + await this.state.storage.delete(leaseKey) + await this.state.storage.deleteAlarm() + }) + } + + private async handleRequest(request: Request): Promise { + if (request.method !== 'POST') return response(405) + let input: unknown + try { + input = await request.json() + } catch { + return response(400) + } + if (!isLeaseRequest(input)) return response(400) + + const operation = new URL(request.url).pathname + if (operation === '/acquire') return this.acquire(input) + if (operation === '/renew') return this.renew(input) + if (operation === '/release') return this.release(input.ownerId) + return response(404) + } + + private async acquire(input: LeaseRequest): Promise { + const current = await this.readLease() + if (current && current.expiresAt > Date.now()) return response(409) + await this.writeLease(input) + return response(200) + } + + private async renew(input: LeaseRequest): Promise { + const current = await this.readLease() + if ( + !current || + current.ownerId !== input.ownerId || + current.expiresAt <= Date.now() + ) { + return response(409) + } + await this.writeLease(input) + return response(200) + } + + private async release(ownerId: string): Promise { + const current = await this.readLease() + if (!current || current.ownerId !== ownerId) return response(409) + await this.state.storage.delete(leaseKey) + await this.state.storage.deleteAlarm() + return response(200) + } + + private async readLease(): Promise { + const value = await this.state.storage.get(leaseKey) + if (value === undefined) return undefined + if (!isLeaseRecord(value)) { + throw new Error('Durable Object lock lease is invalid') + } + return value + } + + private async writeLease(input: LeaseRequest): Promise { + const lease: LeaseRecord = { + ownerId: input.ownerId, + expiresAt: Date.now() + input.leaseDurationMs, + } + await this.state.storage.put(leaseKey, lease) + await this.state.storage.setAlarm(lease.expiresAt) + } + + private serialize(operation: () => Promise): Promise { + const result = this.operationChain.then(operation, operation) + this.operationChain = result.then( + () => undefined, + () => undefined, + ) + return result + } +} + +function resolveOptions( + options: DurableObjectLockStoreOptions, +): ResolvedLockOptions { + const leaseDurationMs = options.leaseDurationMs ?? 30_000 + const renewIntervalMs = options.renewIntervalMs ?? leaseDurationMs / 3 + const acquireTimeoutMs = options.acquireTimeoutMs ?? 30_000 + const retryDelayMs = options.retryDelayMs ?? 50 + for (const [name, value] of [ + ['leaseDurationMs', leaseDurationMs], + ['renewIntervalMs', renewIntervalMs], + ['acquireTimeoutMs', acquireTimeoutMs], + ['retryDelayMs', retryDelayMs], + ] as const) { + if (!Number.isFinite(value) || value <= 0) { + throw new RangeError(`${name} must be a positive finite number`) + } + } + if (renewIntervalMs >= leaseDurationMs) { + throw new RangeError('renewIntervalMs must be less than leaseDurationMs') + } + return { + leaseDurationMs, + renewIntervalMs, + acquireTimeoutMs, + retryDelayMs, + createOwnerId: options.createOwnerId ?? (() => crypto.randomUUID()), + } +} + +function sleep(milliseconds: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal?.aborted) { + resolve() + return + } + const timeout = setTimeout(() => { + signal?.removeEventListener('abort', stopWaiting) + resolve() + }, milliseconds) + const stopWaiting = () => { + clearTimeout(timeout) + resolve() + } + signal?.addEventListener('abort', stopWaiting, { once: true }) + }) +} + +function isAborted(signal: AbortSignal): boolean { + return signal.aborted +} + +async function settle(promise: Promise): Promise> { + try { + return { status: 'fulfilled', value: await promise } + } catch (error) { + return { status: 'rejected', reason: error } + } +} + +async function lockOperation( + stub: DurableObjectStubBinding, + operation: 'acquire' | 'renew' | 'release', + ownerId: string, + leaseDurationMs: number, +): Promise { + return stub.fetch( + new Request(`https://tanstack-ai-lock.invalid/${operation}`, { + method: 'POST', + body: JSON.stringify({ ownerId, leaseDurationMs }), + headers: { 'content-type': 'application/json' }, + }), + ) +} + +function operationError(operation: string, status: number): Error { + return new Error( + `Durable Object lock ${operation} failed with status ${status}`, + ) +} + +async function acquireLease( + stub: DurableObjectStubBinding, + ownerId: string, + options: ResolvedLockOptions, +): Promise { + const deadline = Date.now() + options.acquireTimeoutMs + for (;;) { + const result = await lockOperation( + stub, + 'acquire', + ownerId, + options.leaseDurationMs, + ) + if (result.ok) return + if (result.status !== 409) throw operationError('acquire', result.status) + if (Date.now() >= deadline) { + throw new Error('Durable Object lock acquire timed out') + } + await sleep(options.retryDelayMs) + } +} + +async function renewLeaseUntilFinished( + stub: DurableObjectStubBinding, + ownerId: string, + options: ResolvedLockOptions, + signal: AbortSignal, +): Promise { + for (;;) { + if (isAborted(signal)) return + await sleep(options.renewIntervalMs, signal) + if (isAborted(signal)) return + const result = await lockOperation( + stub, + 'renew', + ownerId, + options.leaseDurationMs, + ) + if (!result.ok) throw operationError('renew', result.status) + } +} + +function throwFailures(failures: Array): void { + if (failures.length === 1) throw failures[0] + if (failures.length > 1) { + throw new AggregateError(failures, 'Durable Object lock operation failed') + } +} + +/** Create a distributed LockStore backed by one Durable Object per lock key. */ +export function createDurableObjectLockStore( + namespace: DurableObjectNamespaceBinding, + lockOptions: DurableObjectLockStoreOptions = {}, +): LockStore { + const options = resolveOptions(lockOptions) + return { + async withLock( + key: string, + fn: (signal: AbortSignal) => Promise, + ): Promise { + const ownerId = options.createOwnerId() + const stub = namespace.get(namespace.idFromName(key)) + await acquireLease(stub, ownerId, options) + + const workFinished = new AbortController() + const leaseOwned = new AbortController() + const workResultPromise = settle( + Promise.resolve().then(() => fn(leaseOwned.signal)), + ) + const renewalResultPromise = settle( + renewLeaseUntilFinished( + stub, + ownerId, + options, + workFinished.signal, + ).catch((error: unknown) => { + leaseOwned.abort(error) + throw error + }), + ) + const workResult = await workResultPromise + workFinished.abort() + const renewalResult = await renewalResultPromise + // A 409 from `release` means the lease was no longer ours (it expired, or + // another owner acquired it) by the time the critical section finished. + // We surface that as a thrown error and let `withLock` reject even though + // the work itself may have completed: a lost lease means mutual exclusion + // could have been violated mid-section, so the caller must NOT treat the + // result as if it ran under the lock. `leaseOwned` is aborted when renewal + // fails, so a well-behaved critical section will already have stopped. + const releaseResult = await settle( + lockOperation(stub, 'release', ownerId, options.leaseDurationMs).then( + (result) => { + if (!result.ok) throw operationError('release', result.status) + }, + ), + ) + + const failures: Array = [] + if (workResult.status === 'rejected') failures.push(workResult.reason) + if (renewalResult.status === 'rejected') { + failures.push(renewalResult.reason) + } + if (releaseResult.status === 'rejected') { + failures.push(releaseResult.reason) + } + throwFailures(failures) + if (workResult.status === 'rejected') throw workResult.reason + return workResult.value + }, + } +} diff --git a/packages/ai-persistence-cloudflare/src/migration-cli.ts b/packages/ai-persistence-cloudflare/src/migration-cli.ts new file mode 100644 index 000000000..ff3ecc17a --- /dev/null +++ b/packages/ai-persistence-cloudflare/src/migration-cli.ts @@ -0,0 +1,125 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { d1Migrations } from './migrations' + +export interface MigrationCliOutput { + writeStdout: (value: string) => void +} + +export class MigrationCliError extends Error { + constructor(message: string) { + super(message) + this.name = 'MigrationCliError' + } +} + +const usage = `Usage: tanstack-ai-cloudflare-migrations (--out | --stdout) [--force] + +Options: + --out Copy the ordered Cloudflare D1 migration files. + --stdout Print the ordered migration SQL. + --force Replace divergent files when copying. + --help Show this help. +` + +interface ParsedArguments { + force: boolean + help: boolean + outDirectory?: string + stdout: boolean +} + +function parseArguments(args: ReadonlyArray): ParsedArguments { + let force = false + let help = false + let outDirectory: string | undefined + let stdout = false + + for (let index = 0; index < args.length; index++) { + const argument = args[index] + if (argument === '--force') { + force = true + } else if (argument === '--help' || argument === '-h') { + help = true + } else if (argument === '--stdout') { + stdout = true + } else if (argument === '--out') { + const value = args[index + 1] + if (!value || value.startsWith('-')) { + throw new MigrationCliError('--out requires a directory.') + } + if (outDirectory !== undefined) { + throw new MigrationCliError('--out may only be provided once.') + } + outDirectory = value + index++ + } else { + throw new MigrationCliError(`Unknown argument: ${argument ?? ''}`) + } + } + + return { force, help, outDirectory, stdout } +} + +function isMissingFileError(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +async function readExistingFile(path: string): Promise { + try { + return await readFile(path, 'utf8') + } catch (error) { + if (isMissingFileError(error)) return undefined + throw error + } +} + +/** Execute the D1 migration asset CLI. Exported for deterministic CLI tests. */ +export async function runCloudflareMigrationsCli( + args: ReadonlyArray, + output: MigrationCliOutput = { + writeStdout(value) { + process.stdout.write(value) + }, + }, +): Promise { + const parsed = parseArguments(args) + if (parsed.help) { + output.writeStdout(usage) + return + } + + const outputCount = + Number(parsed.stdout) + Number(parsed.outDirectory != null) + if (outputCount !== 1) { + throw new MigrationCliError( + 'Provide exactly one output mode: --out or --stdout.', + ) + } + if (parsed.stdout) { + if (parsed.force) { + throw new MigrationCliError('--force can only be used with --out.') + } + output.writeStdout( + `${d1Migrations.map((migration) => migration.sql.trimEnd()).join('\n\n')}\n`, + ) + return + } + + const outDirectory = parsed.outDirectory + if (!outDirectory) { + throw new MigrationCliError('--out requires a directory.') + } + await mkdir(outDirectory, { recursive: true }) + for (const migration of d1Migrations) { + const destination = join(outDirectory, migration.filename) + const existing = await readExistingFile(destination) + if (existing === migration.sql) continue + if (existing !== undefined && !parsed.force) { + throw new MigrationCliError( + `Refusing to overwrite divergent migration file: ${destination}. Re-run with --force to replace it.`, + ) + } + await writeFile(destination, migration.sql, 'utf8') + } +} diff --git a/packages/ai-persistence-cloudflare/src/migrations.ts b/packages/ai-persistence-cloudflare/src/migrations.ts new file mode 100644 index 000000000..0d24c5b49 --- /dev/null +++ b/packages/ai-persistence-cloudflare/src/migrations.ts @@ -0,0 +1,25 @@ +import initialMigrationSql from './assets/0000_tanstack_ai_initial.sql?raw' + +export interface D1Migration { + id: string + filename: string + sql: string +} + +/** + * PROVENANCE: `assets/0000_tanstack_ai_initial.sql` is a byte-for-byte copy of + * the Drizzle asset + * (`@tanstack/ai-persistence-drizzle`'s `src/assets/0000_tanstack_ai_initial.sql`). + * `createD1Stores` runs the Drizzle stores against a D1 database migrated with + * this SQL, so the copy MUST stay identical to Drizzle's. The test "cloudflare + * D1 asset matches the drizzle asset" (`tests/migrations.test.ts`) fails on any + * drift. + */ +/** Ordered D1 migrations for messages, runs, interrupts, and metadata. */ +export const d1Migrations: ReadonlyArray = [ + { + id: '0000_tanstack_ai_initial', + filename: '0000_tanstack_ai_initial.sql', + sql: initialMigrationSql, + }, +] diff --git a/packages/ai-persistence-cloudflare/tests/api-types.test-d.ts b/packages/ai-persistence-cloudflare/tests/api-types.test-d.ts new file mode 100644 index 000000000..af7eca8b8 --- /dev/null +++ b/packages/ai-persistence-cloudflare/tests/api-types.test-d.ts @@ -0,0 +1,65 @@ +/// +import { expectTypeOf } from 'vitest' +import { composePersistence } from '@tanstack/ai-persistence' +import { + CloudflareLockDurableObject, + cloudflarePersistence, +} from '../src/index' +import type { + InterruptStore, + MessageStore, + MetadataStore, + RunStore, +} from '@tanstack/ai-persistence' +import type { LockStore } from '@tanstack/ai' + +declare const d1: D1Database +declare const durableObjects: DurableObjectNamespace +declare const durableObjectState: DurableObjectState + +new CloudflareLockDurableObject(durableObjectState) + +expectTypeOf(cloudflarePersistence({}).stores).toEqualTypeOf<{}>() +expectTypeOf(cloudflarePersistence({ d1 }).stores).toEqualTypeOf<{ + messages: MessageStore + runs: RunStore + interrupts: InterruptStore + metadata: MetadataStore +}>() +expectTypeOf(cloudflarePersistence({ durableObjects }).stores).toEqualTypeOf<{ + locks: LockStore +}>() +expectTypeOf( + cloudflarePersistence({ d1, durableObjects }).stores, +).toEqualTypeOf<{ + messages: MessageStore + runs: RunStore + interrupts: InterruptStore + metadata: MetadataStore + locks: LockStore +}>() + +declare const optionalD1: D1Database | undefined +expectTypeOf(cloudflarePersistence({ d1: optionalD1 }).stores).toEqualTypeOf<{ + messages?: MessageStore + runs?: RunStore + interrupts?: InterruptStore + metadata?: MetadataStore +}>() + +const d1Persistence = cloudflarePersistence({ d1 }) +declare const customInterrupts: InterruptStore +const replaced = composePersistence(d1Persistence, { + overrides: { interrupts: customInterrupts }, +}) +expectTypeOf(replaced.stores.interrupts).toEqualTypeOf() +expectTypeOf(replaced.stores.runs).toEqualTypeOf() + +const removed = composePersistence(d1Persistence, { + overrides: { interrupts: false }, +}) +expectTypeOf(removed.stores).toEqualTypeOf<{ + messages: MessageStore + runs: RunStore + metadata: MetadataStore +}>() diff --git a/packages/ai-persistence-cloudflare/tests/locks.test.ts b/packages/ai-persistence-cloudflare/tests/locks.test.ts new file mode 100644 index 000000000..c40822089 --- /dev/null +++ b/packages/ai-persistence-cloudflare/tests/locks.test.ts @@ -0,0 +1,359 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + CloudflareLockDurableObject, + createDurableObjectLockStore, +} from '../src/index' +import type { + DurableObjectNamespaceBinding, + DurableObjectStubBinding, + LockDurableObjectStorage, +} from '../src/index' + +class FakeLockStorage implements LockDurableObjectStorage { + readonly values = new Map() + alarm: number | undefined + + get(key: string): Promise { + return Promise.resolve(this.values.get(key)) + } + + put(key: string, value: unknown): Promise { + this.values.set(key, value) + return Promise.resolve() + } + + delete(key: string): Promise { + return Promise.resolve(this.values.delete(key)) + } + + setAlarm(timestamp: number): Promise { + this.alarm = timestamp + return Promise.resolve() + } + + deleteAlarm(): Promise { + this.alarm = undefined + return Promise.resolve() + } +} + +function request( + operation: 'acquire' | 'renew' | 'release', + ownerId: string, + leaseDurationMs = 100, +): Request { + return new Request(`https://lock.invalid/${operation}`, { + method: 'POST', + body: JSON.stringify({ ownerId, leaseDurationMs }), + headers: { 'content-type': 'application/json' }, + }) +} + +function deferred() { + let resolve: (value: T) => void = () => undefined + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve + }) + return { promise, resolve } +} + +function waitForAbort(signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve() + return new Promise((resolve) => { + signal.addEventListener('abort', () => resolve(), { once: true }) + }) +} + +async function ownerIdFrom(request: Request): Promise { + const body: unknown = await request.clone().json() + if ( + typeof body !== 'object' || + body === null || + !('ownerId' in body) || + typeof body.ownerId !== 'string' + ) { + return undefined + } + return body.ownerId +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('CloudflareLockDurableObject', () => { + it('acquires, renews, and releases an owner-scoped lease', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const storage = new FakeLockStorage() + const durableObject = new CloudflareLockDurableObject({ storage }) + + expect((await durableObject.fetch(request('acquire', 'one'))).status).toBe( + 200, + ) + expect((await durableObject.fetch(request('acquire', 'two'))).status).toBe( + 409, + ) + vi.setSystemTime(1_050) + expect((await durableObject.fetch(request('renew', 'one'))).status).toBe( + 200, + ) + expect(storage.alarm).toBe(1_150) + expect((await durableObject.fetch(request('release', 'two'))).status).toBe( + 409, + ) + expect((await durableObject.fetch(request('release', 'one'))).status).toBe( + 200, + ) + expect((await durableObject.fetch(request('acquire', 'two'))).status).toBe( + 200, + ) + }) + + it('expires a lease from the Durable Object alarm', async () => { + vi.useFakeTimers() + vi.setSystemTime(2_000) + const storage = new FakeLockStorage() + const durableObject = new CloudflareLockDurableObject({ storage }) + await durableObject.fetch(request('acquire', 'one')) + + vi.setSystemTime(2_101) + await durableObject.alarm() + + expect((await durableObject.fetch(request('acquire', 'two'))).status).toBe( + 200, + ) + }) +}) + +describe('Durable Object LockStore', () => { + it('renews a long-running critical section and awaits release', async () => { + vi.useFakeTimers() + vi.setSystemTime(3_000) + const storage = new FakeLockStorage() + const server = new CloudflareLockDurableObject({ storage }) + const operations: Array = [] + const stub: DurableObjectStubBinding = { + fetch(input) { + const requestInput = + input instanceof Request ? input : new Request(input) + operations.push(new URL(requestInput.url).pathname) + return server.fetch(requestInput) + }, + } + const namespace: DurableObjectNamespaceBinding = { + idFromName: (name) => name, + get: () => stub, + } + const store = createDurableObjectLockStore(namespace, { + leaseDurationMs: 100, + renewIntervalMs: 40, + retryDelayMs: 5, + acquireTimeoutMs: 100, + createOwnerId: () => 'owner', + }) + const work = deferred() + const result = store.withLock('workspace', () => work.promise) + + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(41) + expect(operations).toContain('/renew') + work.resolve('done') + await expect(result).resolves.toBe('done') + expect(operations.at(-1)).toBe('/release') + }) + + it('surfaces renewal failure after user work settles and still releases', async () => { + vi.useFakeTimers() + const operations: Array = [] + const stub: DurableObjectStubBinding = { + fetch(input) { + const requestInput = + input instanceof Request ? input : new Request(input) + const operation = new URL(requestInput.url).pathname + operations.push(operation) + return Promise.resolve( + new Response(null, { status: operation === '/renew' ? 503 : 200 }), + ) + }, + } + const namespace: DurableObjectNamespaceBinding = { + idFromName: (name) => name, + get: () => stub, + } + const store = createDurableObjectLockStore(namespace, { + leaseDurationMs: 100, + renewIntervalMs: 40, + createOwnerId: () => 'owner', + }) + const work = deferred() + const result = store.withLock('workspace', () => work.promise) + + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(41) + work.resolve('done') + + await expect(result).rejects.toThrow(/renew.*503/i) + expect(operations.at(-1)).toBe('/release') + }) + + it('aborts a lost lease before another owner enters after expiry', async () => { + vi.useFakeTimers() + vi.setSystemTime(4_000) + const storage = new FakeLockStorage() + const server = new CloudflareLockDurableObject({ storage }) + const stub: DurableObjectStubBinding = { + async fetch(input) { + const requestInput = + input instanceof Request ? input : new Request(input) + const operation = new URL(requestInput.url).pathname + const ownerId = await ownerIdFrom(requestInput) + if ( + ownerId === 'owner-1' && + (operation === '/renew' || operation === '/release') + ) { + return new Response(null, { status: 503 }) + } + return server.fetch(requestInput) + }, + } + const namespace: DurableObjectNamespaceBinding = { + idFromName: (name) => name, + get: () => stub, + } + const firstStore = createDurableObjectLockStore(namespace, { + leaseDurationMs: 100, + renewIntervalMs: 40, + retryDelayMs: 5, + acquireTimeoutMs: 200, + createOwnerId: () => 'owner-1', + }) + const secondStore = createDurableObjectLockStore(namespace, { + leaseDurationMs: 100, + renewIntervalMs: 40, + retryDelayMs: 5, + acquireTimeoutMs: 200, + createOwnerId: () => 'owner-2', + }) + const legacyStop = deferred() + let active = 0 + let maximumActive = 0 + let firstAborted = false + + const first = firstStore.withLock('workspace', async (signal) => { + active += 1 + maximumActive = Math.max(maximumActive, active) + if (signal instanceof AbortSignal) { + await waitForAbort(signal) + firstAborted = signal.aborted + } else { + await legacyStop.promise + } + active -= 1 + }) + const firstError = first.then( + () => undefined, + (error: unknown) => error, + ) + await vi.advanceTimersByTimeAsync(0) + const second = secondStore.withLock('workspace', async () => { + active += 1 + maximumActive = Math.max(maximumActive, active) + active -= 1 + return 'second' + }) + + await vi.advanceTimersByTimeAsync(110) + legacyStop.resolve() + await expect(second).resolves.toBe('second') + await expect(firstError).resolves.toBeInstanceOf(AggregateError) + + expect(firstAborted).toBe(true) + expect(maximumActive).toBe(1) + }) + + it('surfaces release failure', async () => { + const stub: DurableObjectStubBinding = { + fetch(input) { + const requestInput = + input instanceof Request ? input : new Request(input) + const operation = new URL(requestInput.url).pathname + return Promise.resolve( + new Response(null, { status: operation === '/release' ? 503 : 200 }), + ) + }, + } + const namespace: DurableObjectNamespaceBinding = { + idFromName: (name) => name, + get: () => stub, + } + const store = createDurableObjectLockStore(namespace, { + createOwnerId: () => 'owner', + }) + + await expect(store.withLock('workspace', async () => 42)).rejects.toThrow( + /release.*503/i, + ) + }) + + it('preserves a critical section rejection with an undefined reason', async () => { + const namespace: DurableObjectNamespaceBinding = { + idFromName: (name) => name, + get: () => ({ fetch: () => Promise.resolve(new Response()) }), + } + const store = createDurableObjectLockStore(namespace) + + await expect( + store.withLock('workspace', () => Promise.reject(undefined)), + ).rejects.toBeUndefined() + }) + + it('aggregates critical section and release failures', async () => { + const workError = new Error('work failed') + const stub: DurableObjectStubBinding = { + fetch(input) { + const requestInput = + input instanceof Request ? input : new Request(input) + const operation = new URL(requestInput.url).pathname + return Promise.resolve( + new Response(null, { status: operation === '/release' ? 503 : 200 }), + ) + }, + } + const namespace: DurableObjectNamespaceBinding = { + idFromName: (name) => name, + get: () => stub, + } + const store = createDurableObjectLockStore(namespace) + + const error = await store + .withLock('workspace', () => Promise.reject(workError)) + .then( + () => undefined, + (reason: unknown) => reason, + ) + + expect(error).toBeInstanceOf(AggregateError) + expect(error).toMatchObject({ + errors: [ + workError, + expect.objectContaining({ + message: expect.stringMatching(/release.*503/i), + }), + ], + }) + }) + + it('rejects invalid lease timing before acquiring', () => { + const namespace: DurableObjectNamespaceBinding = { + idFromName: (name) => name, + get: () => ({ fetch: () => Promise.resolve(new Response()) }), + } + expect(() => + createDurableObjectLockStore(namespace, { + leaseDurationMs: 100, + renewIntervalMs: 100, + }), + ).toThrow(/renewIntervalMs.*leaseDurationMs/) + }) +}) diff --git a/packages/ai-persistence-cloudflare/tests/migration-cli.test.ts b/packages/ai-persistence-cloudflare/tests/migration-cli.test.ts new file mode 100644 index 000000000..5e5fd529b --- /dev/null +++ b/packages/ai-persistence-cloudflare/tests/migration-cli.test.ts @@ -0,0 +1,65 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { d1Migrations } from '../src/migrations' +import { runCloudflareMigrationsCli } from '../src/migration-cli' + +const temporaryDirectories: Array = [] + +async function createTemporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'tanstack-ai-cloudflare-cli-')) + temporaryDirectories.push(directory) + return directory +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ) +}) + +describe('Cloudflare D1 migrations CLI', () => { + it('prints canonical migration SQL to stdout', async () => { + let output = '' + await runCloudflareMigrationsCli(['--stdout'], { + writeStdout(value) { + output += value + }, + }) + + expect(output).toBe(`${d1Migrations[0]?.sql.trimEnd()}\n`) + }) + + it('copies migrations without overwriting divergent files by default', async () => { + const directory = await createTemporaryDirectory() + await runCloudflareMigrationsCli(['--out', directory]) + + const migration = d1Migrations[0] + expect(migration).toBeDefined() + if (!migration) return + const destination = join(directory, migration.filename) + expect(await readFile(destination, 'utf8')).toBe(migration.sql) + + await writeFile(destination, 'user-owned contents', 'utf8') + await expect( + runCloudflareMigrationsCli(['--out', directory]), + ).rejects.toThrow(/refusing to overwrite/i) + expect(await readFile(destination, 'utf8')).toBe('user-owned contents') + + await runCloudflareMigrationsCli(['--out', directory, '--force']) + expect(await readFile(destination, 'utf8')).toBe(migration.sql) + }) + + it('fails on ambiguous or incomplete output options', async () => { + const directory = await createTemporaryDirectory() + await expect(runCloudflareMigrationsCli([])).rejects.toThrow( + /--out.*--stdout/i, + ) + await expect( + runCloudflareMigrationsCli(['--stdout', '--out', directory]), + ).rejects.toThrow(/exactly one/i) + }) +}) diff --git a/packages/ai-persistence-cloudflare/tests/migrations.test.ts b/packages/ai-persistence-cloudflare/tests/migrations.test.ts new file mode 100644 index 000000000..f2ddeeed6 --- /dev/null +++ b/packages/ai-persistence-cloudflare/tests/migrations.test.ts @@ -0,0 +1,53 @@ +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { d1Migrations } from '../src/index' + +async function readAsset(url: string): Promise { + return readFile(fileURLToPath(new URL(url, import.meta.url)), 'utf8') +} + +describe('D1 migrations', () => { + it('exports the canonical structured-state migration', () => { + expect(d1Migrations).toHaveLength(1) + expect(d1Migrations[0]).toMatchObject({ + id: '0000_tanstack_ai_initial', + filename: '0000_tanstack_ai_initial.sql', + }) + const sql = d1Migrations[0]?.sql ?? '' + expect(sql).toMatch(/CREATE TABLE [`"]messages[`"]/) + expect(sql).toMatch(/CREATE TABLE [`"]runs[`"]/) + expect(sql).toMatch(/CREATE TABLE [`"]interrupts[`"]/) + expect(sql).toMatch(/CREATE TABLE [`"]metadata[`"]/) + }) + + it('keeps the published migration equal to the embedded asset', async () => { + const embedded = await readFile( + fileURLToPath( + new URL('../src/assets/0000_tanstack_ai_initial.sql', import.meta.url), + ), + 'utf8', + ) + const published = await readFile( + fileURLToPath( + new URL('../migrations/0000_tanstack_ai_initial.sql', import.meta.url), + ), + 'utf8', + ) + expect(published).toBe(embedded) + expect(d1Migrations[0]?.sql).toBe(embedded) + }) + + // `createD1Stores` runs the Drizzle stores against a D1 database migrated + // with this copy of the Drizzle migration, so it must stay byte-for-byte + // identical to the Drizzle asset. Drift breaks the stores at runtime. + it('cloudflare D1 asset matches the drizzle asset', async () => { + const cloudflareSql = await readAsset( + '../src/assets/0000_tanstack_ai_initial.sql', + ) + const drizzleSql = await readAsset( + '../../ai-persistence-drizzle/src/assets/0000_tanstack_ai_initial.sql', + ) + expect(cloudflareSql).toBe(drizzleSql) + }) +}) diff --git a/packages/ai-persistence-cloudflare/tests/package-contract.test.ts b/packages/ai-persistence-cloudflare/tests/package-contract.test.ts new file mode 100644 index 000000000..8c8039860 --- /dev/null +++ b/packages/ai-persistence-cloudflare/tests/package-contract.test.ts @@ -0,0 +1,46 @@ +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import packageJson from '../package.json' + +describe('Cloudflare package contract', () => { + it('starts at 0.0.1 and publishes the root, CLI, and D1 assets', () => { + expect(packageJson.exports).toEqual({ + '.': { + types: './dist/esm/index.d.ts', + import: './dist/esm/index.js', + }, + }) + expect(packageJson.bin).toEqual({ + 'tanstack-ai-cloudflare-migrations': + './bin/tanstack-ai-cloudflare-migrations.mjs', + }) + expect(packageJson.files).toEqual( + expect.arrayContaining(['bin', 'dist', 'migrations', 'src']), + ) + }) + + it('keeps Node built-ins, Buffer, and the CLI out of the root graph', async () => { + const rootFiles = [ + 'bindings.ts', + 'd1.ts', + 'index.ts', + 'locks.ts', + 'migrations.ts', + ] + for (const filename of rootFiles) { + const contents = await readFile( + fileURLToPath(new URL(`../src/${filename}`, import.meta.url)), + 'utf8', + ) + expect(contents, filename).not.toMatch(/from ['"]node:/) + expect(contents, filename).not.toMatch(/\bBuffer\b/) + } + const root = await readFile( + fileURLToPath(new URL('../src/index.ts', import.meta.url)), + 'utf8', + ) + expect(root).not.toMatch(/migration-cli/) + expect(root).not.toMatch(/\.\/cli/) + }) +}) diff --git a/packages/ai-persistence-cloudflare/tests/runtime.conformance.test.ts b/packages/ai-persistence-cloudflare/tests/runtime.conformance.test.ts new file mode 100644 index 000000000..fcfdee664 --- /dev/null +++ b/packages/ai-persistence-cloudflare/tests/runtime.conformance.test.ts @@ -0,0 +1,77 @@ +/// +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { Miniflare } from 'miniflare' +import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' +import { cloudflarePersistence, d1Migrations } from '../src/index' +import { composePersistence } from '@tanstack/ai-persistence' +import type { AIPersistence, InterruptStore } from '@tanstack/ai-persistence' + +interface RuntimeBindings { + AI_DB: D1Database +} + +describe('Cloudflare persistence on Miniflare bindings', () => { + let miniflare: Miniflare + let persistence: AIPersistence + + beforeAll(async () => { + miniflare = new Miniflare({ + compatibilityDate: '2026-06-24', + d1Databases: ['AI_DB'], + modules: true, + script: 'export default { fetch() { return new Response("ok") } }', + }) + const bindings = await miniflare.getBindings() + for (const migration of d1Migrations) { + const statements = migration.sql + .split('--> statement-breakpoint') + .map((statement) => statement.trim()) + .filter((statement) => statement.length > 0) + await bindings.AI_DB.batch( + statements.map((statement) => bindings.AI_DB.prepare(statement)), + ) + } + persistence = cloudflarePersistence({ + d1: bindings.AI_DB, + }) + }) + + afterAll(async () => { + await miniflare.dispose() + }) + + runPersistenceConformance('cloudflare-d1', () => persistence, { + // This composition supplies only a D1 binding (no Durable Object), so + // there is no lock store. + skip: ['locks'], + }) + + it('composes a custom interrupt store while retaining D1 runs', () => { + const customInterrupts: InterruptStore = { + create: () => Promise.resolve(), + resolve: () => Promise.resolve(), + cancel: () => Promise.resolve(), + get: () => Promise.resolve(null), + list: () => Promise.resolve([]), + listPending: () => Promise.resolve([]), + listByRun: () => Promise.resolve([]), + listPendingByRun: () => Promise.resolve([]), + } + const composed = composePersistence(persistence, { + overrides: { interrupts: customInterrupts }, + }) + + expect(composed.stores.interrupts).toBe(customInterrupts) + expect(composed.stores.runs).toBe(persistence.stores.runs) + }) + + it('removes only stores explicitly disabled by an override', () => { + const composed = composePersistence(persistence, { + overrides: { interrupts: false }, + }) + + expect('interrupts' in composed.stores).toBe(false) + expect(composed.stores.runs).toBe(persistence.stores.runs) + expect(composed.stores.messages).toBe(persistence.stores.messages) + }) +}) diff --git a/packages/ai-persistence-cloudflare/tsconfig.json b/packages/ai-persistence-cloudflare/tsconfig.json new file mode 100644 index 000000000..1ca53bbf0 --- /dev/null +++ b/packages/ai-persistence-cloudflare/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "types": ["node", "@cloudflare/workers-types"] + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ai-persistence-cloudflare/vite.config.ts b/packages/ai-persistence-cloudflare/vite.config.ts new file mode 100644 index 000000000..bba59f080 --- /dev/null +++ b/packages/ai-persistence-cloudflare/vite.config.ts @@ -0,0 +1,40 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + '**/types.ts', + ], + include: ['src/**/*.ts'], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts', './src/cli.ts'], + srcDir: './src', + cjs: false, + beforeWriteDeclarationFile(filePath, content) { + if (!/[\\/]index\.d\.ts$/.test(filePath)) return content + return `/// \n${content}` + }, + }), +) diff --git a/packages/ai-persistence-drizzle/bin/tanstack-ai-drizzle-migrations.mjs b/packages/ai-persistence-drizzle/bin/tanstack-ai-drizzle-migrations.mjs new file mode 100755 index 000000000..0a4de6588 --- /dev/null +++ b/packages/ai-persistence-drizzle/bin/tanstack-ai-drizzle-migrations.mjs @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import '../dist/esm/cli.js' diff --git a/packages/ai-persistence-drizzle/bin/tanstack-ai-drizzle-schema.mjs b/packages/ai-persistence-drizzle/bin/tanstack-ai-drizzle-schema.mjs new file mode 100755 index 000000000..c33270c7f --- /dev/null +++ b/packages/ai-persistence-drizzle/bin/tanstack-ai-drizzle-schema.mjs @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import '../dist/esm/schema-cli-main.js' diff --git a/packages/ai-persistence-drizzle/drizzle.config.ts b/packages/ai-persistence-drizzle/drizzle.config.ts new file mode 100644 index 000000000..a3602a3d5 --- /dev/null +++ b/packages/ai-persistence-drizzle/drizzle.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'drizzle-kit' + +/** + * drizzle-kit config for the SQLite schema. Migrations are generated from + * `src/schema.ts` into `drizzle/`, shipped with the package, exposed through + * `sqliteMigrations`, and applied by the Node-only `sqlitePersistence` factory. + * + * Regenerate with `pnpm db:generate` after any schema change. `sqliteMigrations` + * and the D1 sibling load the DUPLICATE at `src/assets/0000_tanstack_ai_initial.sql`, + * which `db:generate` does NOT update — copy the regenerated + * `drizzle/0000_tanstack_ai_initial.sql` over it (the package-contract test + * "keeps the shipped drizzle-kit migration equal to the embedded asset" guards + * the two against drift). + */ +export default defineConfig({ + dialect: 'sqlite', + schema: './src/schema.ts', + out: './drizzle', +}) diff --git a/packages/ai-persistence-drizzle/drizzle/0000_tanstack_ai_initial.sql b/packages/ai-persistence-drizzle/drizzle/0000_tanstack_ai_initial.sql new file mode 100644 index 000000000..3e5570ec1 --- /dev/null +++ b/packages/ai-persistence-drizzle/drizzle/0000_tanstack_ai_initial.sql @@ -0,0 +1,32 @@ +CREATE TABLE `interrupts` ( + `interrupt_id` text PRIMARY KEY NOT NULL, + `run_id` text NOT NULL, + `thread_id` text NOT NULL, + `status` text NOT NULL, + `requested_at` integer NOT NULL, + `resolved_at` integer, + `payload_json` text NOT NULL, + `response_json` text +); +--> statement-breakpoint +CREATE TABLE `messages` ( + `thread_id` text PRIMARY KEY NOT NULL, + `messages_json` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `metadata` ( + `scope` text NOT NULL, + `key` text NOT NULL, + `value_json` text NOT NULL, + PRIMARY KEY(`scope`, `key`) +); +--> statement-breakpoint +CREATE TABLE `runs` ( + `run_id` text PRIMARY KEY NOT NULL, + `thread_id` text NOT NULL, + `status` text NOT NULL, + `started_at` integer NOT NULL, + `finished_at` integer, + `error` text, + `usage_json` text +); diff --git a/packages/ai-persistence-drizzle/drizzle/meta/0000_snapshot.json b/packages/ai-persistence-drizzle/drizzle/meta/0000_snapshot.json new file mode 100644 index 000000000..2f24bec81 --- /dev/null +++ b/packages/ai-persistence-drizzle/drizzle/meta/0000_snapshot.json @@ -0,0 +1,203 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "7c2d61ca-85d3-486d-be8c-ff358edba179", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "interrupts": { + "name": "interrupts", + "columns": { + "interrupt_id": { + "name": "interrupt_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_at": { + "name": "requested_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response_json": { + "name": "response_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "messages_json": { + "name": "messages_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "metadata": { + "name": "metadata", + "columns": { + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value_json": { + "name": "value_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "metadata_scope_key_pk": { + "columns": ["scope", "key"], + "name": "metadata_scope_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "runs": { + "name": "runs", + "columns": { + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage_json": { + "name": "usage_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/ai-persistence-drizzle/drizzle/meta/_journal.json b/packages/ai-persistence-drizzle/drizzle/meta/_journal.json new file mode 100644 index 000000000..29a26ea4b --- /dev/null +++ b/packages/ai-persistence-drizzle/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1784347380743, + "tag": "0000_tanstack_ai_initial", + "breakpoints": true + } + ] +} diff --git a/packages/ai-persistence-drizzle/package.json b/packages/ai-persistence-drizzle/package.json new file mode 100644 index 000000000..cf29897bf --- /dev/null +++ b/packages/ai-persistence-drizzle/package.json @@ -0,0 +1,66 @@ +{ + "name": "@tanstack/ai-persistence-drizzle", + "version": "0.0.0", + "description": "SQLite-family Drizzle persistence for TanStack AI, with an edge-safe bring-your-own database adapter, a Node SQLite factory, and bundled migrations.", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-persistence-drizzle" + }, + "keywords": [ + "ai", + "tanstack", + "persistence", + "drizzle", + "sqlite" + ], + "type": "module", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + }, + "./sqlite": { + "types": "./dist/esm/sqlite.d.ts", + "import": "./dist/esm/sqlite.js" + } + }, + "bin": { + "tanstack-ai-drizzle-migrations": "./bin/tanstack-ai-drizzle-migrations.mjs", + "tanstack-ai-drizzle-schema": "./bin/tanstack-ai-drizzle-schema.mjs" + }, + "files": [ + "bin", + "dist", + "src", + "drizzle", + "drizzle.config.ts" + ], + "scripts": { + "build": "vite build", + "clean": "premove ./build ./dist", + "db:generate": "drizzle-kit generate", + "lint:fix": "oxlint src --type-aware --fix", + "test:build": "publint --strict", + "test:lib": "vitest", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc", + "test:oxlint": "oxlint src --type-aware" + }, + "peerDependencies": { + "@tanstack/ai": "workspace:^", + "@tanstack/ai-persistence": "workspace:^", + "drizzle-orm": ">=0.44.0" + }, + "devDependencies": { + "@tanstack/ai": "workspace:*", + "@tanstack/ai-persistence": "workspace:*", + "@vitest/coverage-v8": "4.0.14", + "drizzle-kit": "^0.31.0", + "drizzle-orm": "^0.45.0" + } +} diff --git a/packages/ai-persistence-drizzle/src/assets.d.ts b/packages/ai-persistence-drizzle/src/assets.d.ts new file mode 100644 index 000000000..b02bb85ed --- /dev/null +++ b/packages/ai-persistence-drizzle/src/assets.d.ts @@ -0,0 +1,9 @@ +declare module '*.sql?raw' { + const contents: string + export default contents +} + +declare module '*.ts?raw' { + const contents: string + export default contents +} diff --git a/packages/ai-persistence-drizzle/src/assets/0000_tanstack_ai_initial.sql b/packages/ai-persistence-drizzle/src/assets/0000_tanstack_ai_initial.sql new file mode 100644 index 000000000..3e5570ec1 --- /dev/null +++ b/packages/ai-persistence-drizzle/src/assets/0000_tanstack_ai_initial.sql @@ -0,0 +1,32 @@ +CREATE TABLE `interrupts` ( + `interrupt_id` text PRIMARY KEY NOT NULL, + `run_id` text NOT NULL, + `thread_id` text NOT NULL, + `status` text NOT NULL, + `requested_at` integer NOT NULL, + `resolved_at` integer, + `payload_json` text NOT NULL, + `response_json` text +); +--> statement-breakpoint +CREATE TABLE `messages` ( + `thread_id` text PRIMARY KEY NOT NULL, + `messages_json` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `metadata` ( + `scope` text NOT NULL, + `key` text NOT NULL, + `value_json` text NOT NULL, + PRIMARY KEY(`scope`, `key`) +); +--> statement-breakpoint +CREATE TABLE `runs` ( + `run_id` text PRIMARY KEY NOT NULL, + `thread_id` text NOT NULL, + `status` text NOT NULL, + `started_at` integer NOT NULL, + `finished_at` integer, + `error` text, + `usage_json` text +); diff --git a/packages/ai-persistence-drizzle/src/assets/tanstack-ai-schema.ts b/packages/ai-persistence-drizzle/src/assets/tanstack-ai-schema.ts new file mode 100644 index 000000000..86cb944e1 --- /dev/null +++ b/packages/ai-persistence-drizzle/src/assets/tanstack-ai-schema.ts @@ -0,0 +1,73 @@ +/** + * TanStack AI persistence schema — emitted by `tanstack-ai-drizzle-schema`. + * + * This file is yours. Add it to your drizzle-kit `schema` paths so your own + * migration journal owns the DDL, then pass it back to the runtime: + * + * ```ts + * import { drizzlePersistence } from '@tanstack/ai-persistence-drizzle' + * import { schema } from './tanstack-ai-schema' + * + * const persistence = drizzlePersistence(db, { schema }) + * ``` + * + * You may rename tables and columns (or drop the explicit column names below + * and rely on your drizzle `casing` configuration) and add extra app-owned + * columns — keep added columns nullable or defaulted so the runtime's inserts + * succeed, and keep the columns below with these data shapes. + */ +import { integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import type { InterruptRecord, RunStatus } from '@tanstack/ai-persistence' +import type { ModelMessage, TokenUsage } from '@tanstack/ai' + +/** Thread message history (`MessageStore`). */ +export const messages = sqliteTable('messages', { + threadId: text('thread_id').primaryKey(), + messagesJson: text('messages_json', { mode: 'json' }) + .$type>() + .notNull(), +}) + +/** Run lifecycle records (`RunStore`). */ +export const runs = sqliteTable('runs', { + runId: text('run_id').primaryKey(), + threadId: text('thread_id').notNull(), + status: text('status').$type().notNull(), + startedAt: integer('started_at').notNull(), + finishedAt: integer('finished_at'), + error: text('error'), + usageJson: text('usage_json', { mode: 'json' }).$type(), +}) + +/** Interrupt / approval records (`InterruptStore`). */ +export const interrupts = sqliteTable('interrupts', { + interruptId: text('interrupt_id').primaryKey(), + runId: text('run_id').notNull(), + threadId: text('thread_id').notNull(), + status: text('status').$type().notNull(), + requestedAt: integer('requested_at').notNull(), + resolvedAt: integer('resolved_at'), + payloadJson: text('payload_json', { mode: 'json' }) + .$type>() + .notNull(), + responseJson: text('response_json', { mode: 'json' }).$type(), +}) + +/** Scoped key/value metadata (`MetadataStore`). */ +export const metadata = sqliteTable( + 'metadata', + { + scope: text('scope').notNull(), + key: text('key').notNull(), + valueJson: text('value_json', { mode: 'json' }).$type().notNull(), + }, + (table) => [primaryKey({ columns: [table.scope, table.key] })], +) + +/** The full state schema, for `drizzlePersistence(db, { schema })` and drizzle-kit. */ +export const schema = { + messages, + runs, + interrupts, + metadata, +} diff --git a/packages/ai-persistence-drizzle/src/cli.ts b/packages/ai-persistence-drizzle/src/cli.ts new file mode 100644 index 000000000..6b9378a08 --- /dev/null +++ b/packages/ai-persistence-drizzle/src/cli.ts @@ -0,0 +1,10 @@ +#!/usr/bin/env node +import { runDrizzleMigrationsCli } from './migration-cli' + +try { + await runDrizzleMigrationsCli(process.argv.slice(2)) +} catch (error) { + const message = error instanceof Error ? error.message : String(error) + process.stderr.write(`${message}\n`) + process.exitCode = 1 +} diff --git a/packages/ai-persistence-drizzle/src/index.ts b/packages/ai-persistence-drizzle/src/index.ts new file mode 100644 index 000000000..01b4eca9c --- /dev/null +++ b/packages/ai-persistence-drizzle/src/index.ts @@ -0,0 +1,77 @@ +/** + * Drizzle-backed SQLite-family persistence for TanStack AI. + * + * This root entry is safe to import in edge runtimes. Pass an already-created + * and migrated SQLite-compatible Drizzle database, including Cloudflare D1, to + * {@link drizzlePersistence}. Node's built-in SQLite convenience factory lives + * at `@tanstack/ai-persistence-drizzle/sqlite`. + */ +import { schema } from './schema' +import { assertTanstackAiSchema } from './schema-contract' +import { + createInterruptStore, + createMessageStore, + createMetadataStore, + createRunStore, +} from './stores' +import type { TanstackAiSqliteSchema } from './schema-contract' +import type { DrizzleSqliteDb, TanstackAiTables } from './stores' + +export { schema } from './schema' +export { sqliteMigrations } from './migrations' +export { drizzleSchemaFilename, drizzleSchemaSource } from './schema-source' +export { DrizzleSchemaError } from './schema-contract' +export type { TanstackAiSqliteSchema } from './schema-contract' +export type { SqliteMigration } from './migrations' +export type { DrizzleSqliteDb } from './stores' +// Deprecated aliases kept for backward compatibility (both are SQLite-only). +export type { TanstackAiSchema } from './schema-contract' +export type { DrizzleDb } from './stores' + +export interface DrizzlePersistenceOptions { + /** + * Operate over your project's own copy of the TanStack AI schema instead of + * the bundled one — emit it with `tanstack-ai-drizzle-schema`, add it to + * your drizzle-kit schema paths, and pass it here. Table and column database + * names are yours to change (drizzle `casing` transforms included), and + * tables may carry extra app-owned columns as long as they are nullable or + * defaulted. Defaults to the bundled `schema` export. + */ + schema?: TanstackAiSqliteSchema +} + +/** + * Wire TanStack AI persistence stores over a migrated Drizzle SQLite database. + * + * No `locks` store is returned: this backend has no distributed lock primitive, + * and bundling an `InMemoryLockStore` would silently hand multi-instance + * deployments a lock that does not lock across instances. Consumers that need a + * lock (e.g. `withSandbox`) transparently fall back to an in-process + * `InMemoryLockStore`; for cross-instance locking use a distributed backend such + * as the Cloudflare Durable Object lock (`@tanstack/ai-persistence-cloudflare`). + */ +export function drizzlePersistence( + db: DrizzleSqliteDb, + options?: DrizzlePersistenceOptions, +) { + const tables = resolveTables(options?.schema) + return { + stores: { + messages: createMessageStore(db, tables), + runs: createRunStore(db, tables), + interrupts: createInterruptStore(db, tables), + metadata: createMetadataStore(db, tables), + }, + } +} + +function resolveTables(input?: TanstackAiSqliteSchema): TanstackAiTables { + if (!input) return schema + assertTanstackAiSchema(input) + // Safe widening: the stores only depend on the column *data* shapes, which + // `TanstackAiSqliteSchema` pins at the call site, and on the runtime table/column + // objects, which carry their own database names into the generated SQL. The + // concrete `TanstackAiTables` name literals are phantom types the store code + // never relies on. + return input as TanstackAiTables +} diff --git a/packages/ai-persistence-drizzle/src/migration-cli.ts b/packages/ai-persistence-drizzle/src/migration-cli.ts new file mode 100644 index 000000000..c55b2b6cb --- /dev/null +++ b/packages/ai-persistence-drizzle/src/migration-cli.ts @@ -0,0 +1,125 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { sqliteMigrations } from './migrations' + +export interface MigrationCliOutput { + writeStdout: (value: string) => void +} + +export class MigrationCliError extends Error { + constructor(message: string) { + super(message) + this.name = 'MigrationCliError' + } +} + +const usage = `Usage: tanstack-ai-drizzle-migrations (--out | --stdout) [--force] + +Options: + --out Copy the ordered SQLite migration files. + --stdout Print the ordered migration SQL. + --force Replace divergent files when copying. + --help Show this help. +` + +interface ParsedArguments { + force: boolean + help: boolean + outDirectory?: string + stdout: boolean +} + +function parseArguments(args: ReadonlyArray): ParsedArguments { + let force = false + let help = false + let outDirectory: string | undefined + let stdout = false + + for (let index = 0; index < args.length; index++) { + const argument = args[index] + if (argument === '--force') { + force = true + } else if (argument === '--help' || argument === '-h') { + help = true + } else if (argument === '--stdout') { + stdout = true + } else if (argument === '--out') { + const value = args[index + 1] + if (!value || value.startsWith('-')) { + throw new MigrationCliError('--out requires a directory.') + } + if (outDirectory !== undefined) { + throw new MigrationCliError('--out may only be provided once.') + } + outDirectory = value + index++ + } else { + throw new MigrationCliError(`Unknown argument: ${argument ?? ''}`) + } + } + + return { force, help, outDirectory, stdout } +} + +function isMissingFileError(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +async function readExistingFile(path: string): Promise { + try { + return await readFile(path, 'utf8') + } catch (error) { + if (isMissingFileError(error)) return undefined + throw error + } +} + +/** Execute the migration asset CLI. Exported for deterministic CLI tests. */ +export async function runDrizzleMigrationsCli( + args: ReadonlyArray, + output: MigrationCliOutput = { + writeStdout(value) { + process.stdout.write(value) + }, + }, +): Promise { + const parsed = parseArguments(args) + if (parsed.help) { + output.writeStdout(usage) + return + } + + const outputCount = + Number(parsed.stdout) + Number(parsed.outDirectory != null) + if (outputCount !== 1) { + throw new MigrationCliError( + 'Provide exactly one output mode: --out or --stdout.', + ) + } + if (parsed.stdout) { + if (parsed.force) { + throw new MigrationCliError('--force can only be used with --out.') + } + output.writeStdout( + `${sqliteMigrations.map((migration) => migration.sql.trimEnd()).join('\n\n')}\n`, + ) + return + } + + const outDirectory = parsed.outDirectory + if (!outDirectory) { + throw new MigrationCliError('--out requires a directory.') + } + await mkdir(outDirectory, { recursive: true }) + for (const migration of sqliteMigrations) { + const destination = join(outDirectory, migration.filename) + const existing = await readExistingFile(destination) + if (existing === migration.sql) continue + if (existing !== undefined && !parsed.force) { + throw new MigrationCliError( + `Refusing to overwrite divergent migration file: ${destination}. Re-run with --force to replace it.`, + ) + } + await writeFile(destination, migration.sql, 'utf8') + } +} diff --git a/packages/ai-persistence-drizzle/src/migrations.ts b/packages/ai-persistence-drizzle/src/migrations.ts new file mode 100644 index 000000000..889f6426f --- /dev/null +++ b/packages/ai-persistence-drizzle/src/migrations.ts @@ -0,0 +1,20 @@ +import initialMigrationSql from './assets/0000_tanstack_ai_initial.sql?raw' + +/** A canonical SQLite migration bundled with the adapter. */ +export interface SqliteMigration { + /** Stable identifier recorded in the TanStack AI migration table. */ + id: string + /** Filename used by the migration copy CLI. */ + filename: string + /** SQL applied atomically with its migration bookkeeping row. */ + sql: string +} + +/** Ordered canonical migrations for the TanStack AI SQLite schema. */ +export const sqliteMigrations: ReadonlyArray = [ + { + id: '0000_tanstack_ai_initial', + filename: '0000_tanstack_ai_initial.sql', + sql: initialMigrationSql, + }, +] diff --git a/packages/ai-persistence-drizzle/src/schema-cli-main.ts b/packages/ai-persistence-drizzle/src/schema-cli-main.ts new file mode 100644 index 000000000..64c7f6439 --- /dev/null +++ b/packages/ai-persistence-drizzle/src/schema-cli-main.ts @@ -0,0 +1,10 @@ +#!/usr/bin/env node +import { runDrizzleSchemaCli } from './schema-cli' + +try { + await runDrizzleSchemaCli(process.argv.slice(2)) +} catch (error) { + const message = error instanceof Error ? error.message : String(error) + process.stderr.write(`${message}\n`) + process.exitCode = 1 +} diff --git a/packages/ai-persistence-drizzle/src/schema-cli.ts b/packages/ai-persistence-drizzle/src/schema-cli.ts new file mode 100644 index 000000000..9d9de0930 --- /dev/null +++ b/packages/ai-persistence-drizzle/src/schema-cli.ts @@ -0,0 +1,124 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { drizzleSchemaFilename, drizzleSchemaSource } from './schema-source' + +export interface SchemaCliOutput { + writeStdout: (value: string) => void +} + +export class SchemaCliError extends Error { + constructor(message: string) { + super(message) + this.name = 'SchemaCliError' + } +} + +const usage = `Usage: tanstack-ai-drizzle-schema (--out | --stdout) [--force] + +Emits the TanStack AI Drizzle schema module so your project owns it: add the +file to your drizzle-kit schema paths and pass it to drizzlePersistence(db, { schema }). + +Options: + --out Copy the schema module into the directory. + --stdout Print the schema module. + --force Replace a divergent file when copying. + --help Show this help. +` + +interface ParsedArguments { + force: boolean + help: boolean + outDirectory?: string + stdout: boolean +} + +function parseArguments(args: ReadonlyArray): ParsedArguments { + let force = false + let help = false + let outDirectory: string | undefined + let stdout = false + + for (let index = 0; index < args.length; index++) { + const argument = args[index] + if (argument === '--force') { + force = true + } else if (argument === '--help' || argument === '-h') { + help = true + } else if (argument === '--stdout') { + stdout = true + } else if (argument === '--out') { + const value = args[index + 1] + if (!value || value.startsWith('-')) { + throw new SchemaCliError('--out requires a directory.') + } + if (outDirectory !== undefined) { + throw new SchemaCliError('--out may only be provided once.') + } + outDirectory = value + index++ + } else { + throw new SchemaCliError(`Unknown argument: ${argument ?? ''}`) + } + } + + return { force, help, outDirectory, stdout } +} + +function isMissingFileError(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +async function readExistingFile(path: string): Promise { + try { + return await readFile(path, 'utf8') + } catch (error) { + if (isMissingFileError(error)) return undefined + throw error + } +} + +/** Execute the schema asset CLI. Exported for deterministic CLI tests. */ +export async function runDrizzleSchemaCli( + args: ReadonlyArray, + output: SchemaCliOutput = { + writeStdout(value) { + process.stdout.write(value) + }, + }, +): Promise { + const parsed = parseArguments(args) + if (parsed.help) { + output.writeStdout(usage) + return + } + + const outputCount = + Number(parsed.stdout) + Number(parsed.outDirectory != null) + if (outputCount !== 1) { + throw new SchemaCliError( + 'Provide exactly one output mode: --out or --stdout.', + ) + } + if (parsed.stdout) { + if (parsed.force) { + throw new SchemaCliError('--force can only be used with --out.') + } + output.writeStdout(`${drizzleSchemaSource.trimEnd()}\n`) + return + } + + const outDirectory = parsed.outDirectory + if (!outDirectory) { + throw new SchemaCliError('--out requires a directory.') + } + await mkdir(outDirectory, { recursive: true }) + const destination = join(outDirectory, drizzleSchemaFilename) + const existing = await readExistingFile(destination) + if (existing === drizzleSchemaSource) return + if (existing !== undefined && !parsed.force) { + throw new SchemaCliError( + `Refusing to overwrite divergent schema file: ${destination}. Re-run with --force to replace it.`, + ) + } + await writeFile(destination, drizzleSchemaSource, 'utf8') +} diff --git a/packages/ai-persistence-drizzle/src/schema-contract.ts b/packages/ai-persistence-drizzle/src/schema-contract.ts new file mode 100644 index 000000000..9a8d53319 --- /dev/null +++ b/packages/ai-persistence-drizzle/src/schema-contract.ts @@ -0,0 +1,137 @@ +/** + * Structural contract for a user-supplied TanStack AI Drizzle schema. + * + * `drizzlePersistence` accepts any schema whose tables and columns carry the + * required data shapes; table and column **database names are free**. This lets + * a project own the schema file (emitted by `tanstack-ai-drizzle-schema`), + * generate DDL through its own drizzle-kit journal — including projects using + * drizzle's `casing` name transforms — and extend tables with extra columns + * (for example an ownership `user_id`) without falling out of contract. + * + * Only the columns listed here are read or written by the stores. Extra + * columns must therefore be nullable or defaulted so inserts succeed. + */ +import { Column, is } from 'drizzle-orm' +import { SQLiteTable } from 'drizzle-orm/sqlite-core' +import type { AnySQLiteColumn } from 'drizzle-orm/sqlite-core' +import type { InterruptRecord, RunStatus } from '@tanstack/ai-persistence' +import type { ModelMessage, TokenUsage } from '@tanstack/ai' + +/** + * A column whose decoded (`data`) type is `TData`. Name, table name, and + * driver specifics are intentionally unconstrained. + */ +type AiColumn = AnySQLiteColumn<{ data: TData }> + +type AiTable = SQLiteTable & TColumns + +/** + * The schema shape `drizzlePersistence` can operate over. `schema` exported + * from this package satisfies it, as does the file emitted by the + * `tanstack-ai-drizzle-schema` CLI. SQLite-only (the columns are Drizzle + * SQLite columns), which the name makes explicit. + */ +export interface TanstackAiSqliteSchema { + messages: AiTable<{ + threadId: AiColumn + messagesJson: AiColumn> + }> + runs: AiTable<{ + runId: AiColumn + threadId: AiColumn + status: AiColumn + startedAt: AiColumn + finishedAt: AiColumn + error: AiColumn + usageJson: AiColumn + }> + interrupts: AiTable<{ + interruptId: AiColumn + runId: AiColumn + threadId: AiColumn + status: AiColumn + requestedAt: AiColumn + resolvedAt: AiColumn + payloadJson: AiColumn> + responseJson: AiColumn + }> + metadata: AiTable<{ + scope: AiColumn + key: AiColumn + valueJson: AiColumn + }> +} + +/** + * @deprecated Renamed to {@link TanstackAiSqliteSchema} — the contract is + * SQLite-only, which the old name did not signal. + */ +export type TanstackAiSchema = TanstackAiSqliteSchema + +const requiredColumns: { + [K in keyof TanstackAiSqliteSchema]: ReadonlyArray +} = { + messages: ['threadId', 'messagesJson'], + runs: [ + 'runId', + 'threadId', + 'status', + 'startedAt', + 'finishedAt', + 'error', + 'usageJson', + ], + interrupts: [ + 'interruptId', + 'runId', + 'threadId', + 'status', + 'requestedAt', + 'resolvedAt', + 'payloadJson', + 'responseJson', + ], + metadata: ['scope', 'key', 'valueJson'], +} + +const tableKeys = Object.keys(requiredColumns) as Array< + keyof TanstackAiSqliteSchema +> + +/** A user-supplied schema failed the {@link TanstackAiSqliteSchema} contract. */ +export class DrizzleSchemaError extends Error { + constructor(problems: ReadonlyArray) { + super( + `Invalid TanStack AI Drizzle schema:\n${problems + .map((problem) => ` - ${problem}`) + .join('\n')}`, + ) + this.name = 'DrizzleSchemaError' + } +} + +/** + * Assert `input` structurally satisfies the store contract at runtime: every + * table is a Drizzle SQLite table and carries every column property the + * stores reference. Column data shapes are enforced by the + * {@link TanstackAiSqliteSchema} type at compile time and are not re-checked here. + */ +export function assertTanstackAiSchema(input: TanstackAiSqliteSchema): void { + const problems: Array = [] + for (const tableKey of tableKeys) { + const table: unknown = input[tableKey] + if (!is(table, SQLiteTable)) { + problems.push(`\`${tableKey}\` is not a Drizzle SQLite table.`) + continue + } + for (const columnKey of requiredColumns[tableKey]) { + const column: unknown = Reflect.get(table, columnKey) + if (!is(column, Column)) { + problems.push( + `\`${tableKey}.${columnKey}\` is missing or not a Drizzle column.`, + ) + } + } + } + if (problems.length > 0) throw new DrizzleSchemaError(problems) +} diff --git a/packages/ai-persistence-drizzle/src/schema-source.ts b/packages/ai-persistence-drizzle/src/schema-source.ts new file mode 100644 index 000000000..fbce6e2cd --- /dev/null +++ b/packages/ai-persistence-drizzle/src/schema-source.ts @@ -0,0 +1,12 @@ +import source from './assets/tanstack-ai-schema.ts?raw' + +/** Filename used by the schema copy CLI. */ +export const drizzleSchemaFilename = 'tanstack-ai-schema.ts' + +/** + * Source text of the user-facing TanStack AI Drizzle schema module, emitted by + * `tanstack-ai-drizzle-schema`. Structurally identical to this package's + * bundled `schema` (a test enforces it); the copy exists so a project's own + * drizzle-kit journal can own the DDL. + */ +export const drizzleSchemaSource: string = source diff --git a/packages/ai-persistence-drizzle/src/schema.ts b/packages/ai-persistence-drizzle/src/schema.ts new file mode 100644 index 000000000..97ffc79a4 --- /dev/null +++ b/packages/ai-persistence-drizzle/src/schema.ts @@ -0,0 +1,78 @@ +/** + * Drizzle schema for the TanStack AI **state** persistence contract. + * + * Each table mirrors the corresponding record in + * `@tanstack/ai-persistence`'s `types.ts` column-for-column. JSON-valued fields + * are stored in `*_json` text columns; epoch millisecond timestamps are stored + * as integers. + * + * This schema is the single source of truth for migrations: run + * `pnpm db:generate` (drizzle-kit) after any change here. It is also exported + * from the package so bring-your-own-drizzle users can drive their own + * migration workflow against it. + * + * PROVENANCE: `db:generate` emits DDL into `drizzle/`, but the runtime's + * `sqliteMigrations` (and the D1 sibling) load the DUPLICATE at + * `src/assets/0000_tanstack_ai_initial.sql`. `db:generate` alone does NOT touch + * that asset — copy the regenerated `drizzle/0000_tanstack_ai_initial.sql` over + * it, or the shipped migration goes stale. The package-contract test "keeps the + * shipped drizzle-kit migration equal to the embedded asset" fails on drift. + * + * Also keep this in sync with the sibling Prisma schema fragment + * (`@tanstack/ai-persistence-prisma`'s `src/assets/tanstack-ai.prisma`). + */ +import { integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import type { InterruptRecord, RunStatus } from '@tanstack/ai-persistence' +import type { ModelMessage, TokenUsage } from '@tanstack/ai' + +/** Thread message history (`MessageStore`). */ +export const messages = sqliteTable('messages', { + threadId: text('thread_id').primaryKey(), + messagesJson: text('messages_json', { mode: 'json' }) + .$type>() + .notNull(), +}) + +/** Run lifecycle records (`RunStore`). */ +export const runs = sqliteTable('runs', { + runId: text('run_id').primaryKey(), + threadId: text('thread_id').notNull(), + status: text('status').$type().notNull(), + startedAt: integer('started_at').notNull(), + finishedAt: integer('finished_at'), + error: text('error'), + usageJson: text('usage_json', { mode: 'json' }).$type(), +}) + +/** Interrupt / approval records (`InterruptStore`). */ +export const interrupts = sqliteTable('interrupts', { + interruptId: text('interrupt_id').primaryKey(), + runId: text('run_id').notNull(), + threadId: text('thread_id').notNull(), + status: text('status').$type().notNull(), + requestedAt: integer('requested_at').notNull(), + resolvedAt: integer('resolved_at'), + payloadJson: text('payload_json', { mode: 'json' }) + .$type>() + .notNull(), + responseJson: text('response_json', { mode: 'json' }).$type(), +}) + +/** Scoped key/value metadata (`MetadataStore`). */ +export const metadata = sqliteTable( + 'metadata', + { + scope: text('scope').notNull(), + key: text('key').notNull(), + valueJson: text('value_json', { mode: 'json' }).$type().notNull(), + }, + (table) => [primaryKey({ columns: [table.scope, table.key] })], +) + +/** The full state schema, for `drizzlePersistence(db)` and drizzle-kit. */ +export const schema = { + messages, + runs, + interrupts, + metadata, +} diff --git a/packages/ai-persistence-drizzle/src/sqlite-migrations.ts b/packages/ai-persistence-drizzle/src/sqlite-migrations.ts new file mode 100644 index 000000000..1866c339d --- /dev/null +++ b/packages/ai-persistence-drizzle/src/sqlite-migrations.ts @@ -0,0 +1,68 @@ +import type { DatabaseSync } from 'node:sqlite' +import type { SqliteMigration } from './migrations' + +const MIGRATIONS_TABLE = '__tanstack_ai_migrations' + +/** Error raised when a bundled SQLite migration cannot be applied atomically. */ +export class SqliteMigrationError extends Error { + readonly migrationId: string + + constructor(migrationId: string, cause: unknown) { + const detail = cause instanceof Error ? cause.message : String(cause) + super(`Failed to apply SQLite migration "${migrationId}": ${detail}`, { + cause, + }) + this.name = 'SqliteMigrationError' + this.migrationId = migrationId + } +} + +/** + * Apply ordered migrations with each migration SQL and bookkeeping insert in + * the same transaction. A failed migration leaves no partial schema changes or + * applied marker, so retrying after the underlying issue is corrected is safe. + */ +export function applySqliteMigrations( + database: DatabaseSync, + migrations: ReadonlyArray, +): void { + database.exec(` + CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} ( + migration_id TEXT PRIMARY KEY NOT NULL, + applied_at INTEGER NOT NULL + ) + `) + + const findApplied = database.prepare( + `SELECT migration_id FROM ${MIGRATIONS_TABLE} WHERE migration_id = ?`, + ) + const recordApplied = database.prepare( + `INSERT INTO ${MIGRATIONS_TABLE} (migration_id, applied_at) VALUES (?, ?)`, + ) + + for (const migration of migrations) { + database.exec('BEGIN IMMEDIATE') + try { + if (findApplied.get(migration.id) !== undefined) { + database.exec('COMMIT') + continue + } + database.exec(migration.sql) + recordApplied.run(migration.id, Date.now()) + database.exec('COMMIT') + } catch (error) { + try { + database.exec('ROLLBACK') + } catch (rollbackError) { + throw new SqliteMigrationError( + migration.id, + new AggregateError( + [error, rollbackError], + 'Migration failed and its transaction could not be rolled back.', + ), + ) + } + throw new SqliteMigrationError(migration.id, error) + } + } +} diff --git a/packages/ai-persistence-drizzle/src/sqlite.ts b/packages/ai-persistence-drizzle/src/sqlite.ts new file mode 100644 index 000000000..0774c3ff0 --- /dev/null +++ b/packages/ai-persistence-drizzle/src/sqlite.ts @@ -0,0 +1,82 @@ +/** Node-only SQLite convenience factory for TanStack AI persistence. */ +import { mkdirSync } from 'node:fs' +import { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { DatabaseSync } from 'node:sqlite' +import { drizzle } from 'drizzle-orm/sqlite-proxy' +import { sqliteMigrations } from './migrations' +import { applySqliteMigrations } from './sqlite-migrations' +import { drizzlePersistence } from './index' + +export { SqliteMigrationError } from './sqlite-migrations' + +export interface SqlitePersistenceOptions { + /** `:memory:`, a filesystem path, or a `file:`-prefixed filesystem path. */ + url: string + /** Apply the bundled TanStack AI migrations before creating stores. */ + migrate?: boolean +} + +/** Build persistence over Node's built-in SQLite driver. */ +export function sqlitePersistence(options: SqlitePersistenceOptions) { + const filename = normalizeSqliteUrl(options.url) + ensureParentDirectory(filename) + const sqlite = new DatabaseSync(filename) + try { + if (options.migrate) applySqliteMigrations(sqlite, sqliteMigrations) + } catch (error) { + sqlite.close() + throw error + } + + const db = drizzle((sql, params, method) => { + const statement = sqlite.prepare(sql) + if (method === 'run') { + statement.run(...params) + return Promise.resolve({ rows: [] }) + } + if (method === 'get') { + const row = statement.get(...params) + return Promise.resolve({ rows: row ? Object.values(row) : [] }) + } + const rows = statement.all(...params) + return Promise.resolve({ rows: rows.map((row) => Object.values(row)) }) + }) + + const persistence = drizzlePersistence(db) + let closed = false + return { + ...persistence, + close() { + if (closed) return + sqlite.close() + closed = true + }, + } +} + +function normalizeSqliteUrl(url: string): string { + if (url === ':memory:' || url === 'file::memory:') return ':memory:' + if (url.startsWith('file://')) return validateFilename(fileURLToPath(url)) + if (url.startsWith('file:')) { + return validateFilename(url.slice('file:'.length)) + } + const isWindowsPath = /^[A-Za-z]:[\\/]/.test(url) + if (!isWindowsPath && /^[A-Za-z][A-Za-z\d+.-]*:/.test(url)) { + throw new Error(`Unsupported SQLite URL scheme: ${url}`) + } + return validateFilename(url) +} + +function validateFilename(filename: string): string { + if (filename.length === 0 || filename.includes('\0')) { + throw new Error('SQLite URL must identify a non-empty filesystem path') + } + return filename +} + +function ensureParentDirectory(filename: string): void { + if (filename === ':memory:') return + const parent = dirname(filename) + if (parent !== '.') mkdirSync(parent, { recursive: true }) +} diff --git a/packages/ai-persistence-drizzle/src/stores.ts b/packages/ai-persistence-drizzle/src/stores.ts new file mode 100644 index 000000000..c840cdb2a --- /dev/null +++ b/packages/ai-persistence-drizzle/src/stores.ts @@ -0,0 +1,274 @@ +/** + * AIPersistence store implementations over a Drizzle sqlite database. + * + * Each method mirrors the reference in-memory backend + * (`@tanstack/ai-persistence`'s `memory.ts`), including the insert-if-absent + * `InterruptStore.create` and `RunStore.createOrResume` semantics + * (`onConflictDoNothing`). JSON columns are handled by Drizzle's + * `text({ mode: 'json' })`. + */ +import { and, asc, eq } from 'drizzle-orm' +import type { interrupts, runs, schema } from './schema' +import type { BaseSQLiteDatabase } from 'drizzle-orm/sqlite-core' +import type { ModelMessage } from '@tanstack/ai' +import type { + InterruptRecord, + InterruptStore, + MessageStore, + MetadataStore, + RunRecord, + RunStore, +} from '@tanstack/ai-persistence' + +/** + * Any Drizzle sqlite database (better-sqlite3, libsql, node:sqlite proxy, D1, …). + * + * Typed as the schema-agnostic slice of the query builder we actually use, so a + * BYO `db` constructed with any `{ schema }` is assignable regardless of its + * `TFullSchema` (which is invariant on the full `BaseSQLiteDatabase`). + */ +export type DrizzleSqliteDb = Pick< + BaseSQLiteDatabase<'sync' | 'async', unknown>, + 'select' | 'insert' | 'update' | 'delete' +> + +/** + * @deprecated Renamed to {@link DrizzleSqliteDb} — this backend is SQLite-only + * (better-sqlite3, libsql, node:sqlite, D1). The old name did not signal that. + */ +export type DrizzleDb = DrizzleSqliteDb + +/** + * The concrete table set the stores are written against. A user-supplied + * {@link TanstackAiSchema} is validated by `assertTanstackAiSchema` and then + * used through this type: only column *data* shapes matter to the store code — + * table/column database names are carried by the runtime objects, so a schema + * with different names (drizzle `casing` transforms, renamed tables, extra + * app-owned columns) produces correct SQL through the same code paths. + */ +export type TanstackAiTables = typeof schema + +export function createMessageStore( + db: DrizzleSqliteDb, + { messages }: TanstackAiTables, +): MessageStore { + return { + async loadThread(threadId) { + const rows = await db + .select({ messagesJson: messages.messagesJson }) + .from(messages) + .where(eq(messages.threadId, threadId)) + return rows[0]?.messagesJson ?? [] + }, + async saveThread(threadId, msgs: Array) { + await db + .insert(messages) + .values({ threadId, messagesJson: msgs }) + .onConflictDoUpdate({ + target: messages.threadId, + set: { messagesJson: msgs }, + }) + }, + } +} + +function mapRun(row: typeof runs.$inferSelect): RunRecord { + return { + runId: row.runId, + threadId: row.threadId, + status: row.status, + startedAt: row.startedAt, + ...(row.finishedAt != null ? { finishedAt: row.finishedAt } : {}), + ...(row.error != null ? { error: row.error } : {}), + ...(row.usageJson != null ? { usage: row.usageJson } : {}), + } +} + +export function createRunStore( + db: DrizzleSqliteDb, + { runs }: TanstackAiTables, +): RunStore { + const store: RunStore = { + async createOrResume(input) { + const existing = await store.get(input.runId) + if (existing) return existing + const record: RunRecord = { + runId: input.runId, + threadId: input.threadId, + status: input.status ?? 'running', + startedAt: input.startedAt, + } + await db + .insert(runs) + .values({ + runId: record.runId, + threadId: record.threadId, + status: record.status, + startedAt: record.startedAt, + }) + .onConflictDoNothing({ target: runs.runId }) + return (await store.get(input.runId)) ?? record + }, + async update(runId, patch) { + const set: Partial = {} + if (patch.status !== undefined) set.status = patch.status + if (patch.finishedAt !== undefined) set.finishedAt = patch.finishedAt + if (patch.error !== undefined) set.error = patch.error + if (patch.usage !== undefined) set.usageJson = patch.usage + if (Object.keys(set).length === 0) return + await db.update(runs).set(set).where(eq(runs.runId, runId)) + }, + async get(runId) { + const rows = await db.select().from(runs).where(eq(runs.runId, runId)) + const row = rows[0] + return row ? mapRun(row) : null + }, + } + return store +} + +function mapInterrupt(row: typeof interrupts.$inferSelect): InterruptRecord { + return { + interruptId: row.interruptId, + runId: row.runId, + threadId: row.threadId, + status: row.status, + requestedAt: row.requestedAt, + ...(row.resolvedAt != null ? { resolvedAt: row.resolvedAt } : {}), + payload: row.payloadJson, + ...(row.responseJson != null ? { response: row.responseJson } : {}), + } +} + +export function createInterruptStore( + db: DrizzleSqliteDb, + { interrupts }: TanstackAiTables, +): InterruptStore { + return { + async create(record) { + await db + .insert(interrupts) + .values({ + interruptId: record.interruptId, + runId: record.runId, + threadId: record.threadId, + status: 'pending', + requestedAt: record.requestedAt, + payloadJson: record.payload, + responseJson: record.response ?? null, + }) + .onConflictDoNothing({ target: interrupts.interruptId }) + }, + async resolve(interruptId, response) { + await db + .update(interrupts) + .set({ + status: 'resolved', + resolvedAt: Date.now(), + responseJson: response ?? null, + }) + .where(eq(interrupts.interruptId, interruptId)) + }, + async cancel(interruptId) { + await db + .update(interrupts) + .set({ status: 'cancelled', resolvedAt: Date.now() }) + .where(eq(interrupts.interruptId, interruptId)) + }, + async get(interruptId) { + const rows = await db + .select() + .from(interrupts) + .where(eq(interrupts.interruptId, interruptId)) + const row = rows[0] + return row ? mapInterrupt(row) : null + }, + async list(threadId) { + const rows = await db + .select() + .from(interrupts) + .where(eq(interrupts.threadId, threadId)) + .orderBy(asc(interrupts.requestedAt)) + return rows.map(mapInterrupt) + }, + async listPending(threadId) { + const rows = await db + .select() + .from(interrupts) + .where( + and( + eq(interrupts.threadId, threadId), + eq(interrupts.status, 'pending'), + ), + ) + .orderBy(asc(interrupts.requestedAt)) + return rows.map(mapInterrupt) + }, + async listByRun(runId) { + const rows = await db + .select() + .from(interrupts) + .where(eq(interrupts.runId, runId)) + .orderBy(asc(interrupts.requestedAt)) + return rows.map(mapInterrupt) + }, + async listPendingByRun(runId) { + const rows = await db + .select() + .from(interrupts) + .where( + and(eq(interrupts.runId, runId), eq(interrupts.status, 'pending')), + ) + .orderBy(asc(interrupts.requestedAt)) + return rows.map(mapInterrupt) + }, + } +} + +function assertStorableMetadata(value: unknown): void { + if (value == null) { + throw new TypeError( + `TanStack AI metadata values must be defined, non-null JSON; received ${ + value === undefined ? '`undefined`' : '`null`' + }. Use \`delete(scope, key)\` to clear a value.`, + ) + } +} + +export function createMetadataStore( + db: DrizzleSqliteDb, + { metadata }: TanstackAiTables, +): MetadataStore { + return { + async get(scope, key) { + const rows = await db + .select({ valueJson: metadata.valueJson }) + .from(metadata) + .where(and(eq(metadata.scope, scope), eq(metadata.key, key))) + const row = rows[0] + return row ? row.valueJson : null + }, + async set(scope, key, value) { + // SQL backends store JSON text in a NOT NULL column and cannot persist a + // nullish value: `text({ mode: 'json' })` binds a JS `null` as SQL NULL + // (it never serializes it to the text `"null"`), and `undefined` has no + // JSON at all — both violate NOT NULL. Reject nullish with a clear error + // (consistent with the sibling Prisma backend) instead of a cryptic + // driver failure. Unlike the in-memory reference, which round-trips + // nullish; use `delete` to clear. + assertStorableMetadata(value) + await db + .insert(metadata) + .values({ scope, key, valueJson: value }) + .onConflictDoUpdate({ + target: [metadata.scope, metadata.key], + set: { valueJson: value }, + }) + }, + async delete(scope, key) { + await db + .delete(metadata) + .where(and(eq(metadata.scope, scope), eq(metadata.key, key))) + }, + } +} diff --git a/packages/ai-persistence-drizzle/tests/api-types.test-d.ts b/packages/ai-persistence-drizzle/tests/api-types.test-d.ts new file mode 100644 index 000000000..bf414cb40 --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/api-types.test-d.ts @@ -0,0 +1,39 @@ +import { expectTypeOf } from 'vitest' +import type { DrizzleD1Database } from 'drizzle-orm/d1' +import type { + InterruptStore, + MessageStore, + MetadataStore, + RunStore, +} from '@tanstack/ai-persistence' +import { drizzlePersistence, schema } from '../src/index' +import { sqlitePersistence } from '../src/sqlite' +import { schema as emittedSchema } from '../src/assets/tanstack-ai-schema' +import { variantSchema } from './variant-schema' +import type { TanstackAiSqliteSchema } from '../src/index' + +declare const d1Database: DrizzleD1Database +const d1Persistence = drizzlePersistence(d1Database) +expectTypeOf(d1Persistence.stores.messages).toEqualTypeOf() +expectTypeOf(d1Persistence.stores.runs).toEqualTypeOf() +expectTypeOf(d1Persistence.stores.interrupts).toEqualTypeOf() +expectTypeOf(d1Persistence.stores.metadata).toEqualTypeOf() +// No `locks` store: this backend has no distributed lock (see drizzlePersistence). +expectTypeOf(d1Persistence.stores).not.toHaveProperty('locks') + +const sqlite = sqlitePersistence({ url: ':memory:', migrate: true }) +expectTypeOf(sqlite.stores).toEqualTypeOf() +expectTypeOf(sqlite.close).toEqualTypeOf<() => void>() + +// The bundled schema, the emitted schema asset, and a renamed/extended variant +// must all satisfy the injectable schema contract. +expectTypeOf(schema).toExtend() +expectTypeOf(emittedSchema).toExtend() +expectTypeOf(variantSchema).toExtend() + +const customPersistence = drizzlePersistence(d1Database, { + schema: variantSchema, +}) +expectTypeOf(customPersistence.stores).toEqualTypeOf< + typeof d1Persistence.stores +>() diff --git a/packages/ai-persistence-drizzle/tests/custom-schema.test.ts b/packages/ai-persistence-drizzle/tests/custom-schema.test.ts new file mode 100644 index 000000000..7f94e73d0 --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/custom-schema.test.ts @@ -0,0 +1,113 @@ +import { DatabaseSync } from 'node:sqlite' +import { describe, expect, it } from 'vitest' +import { eq } from 'drizzle-orm' +import { sqliteTable, text } from 'drizzle-orm/sqlite-core' +import { drizzle } from 'drizzle-orm/sqlite-proxy' +import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' +import { DrizzleSchemaError, drizzlePersistence, schema } from '../src/index' +import { variantDdl, variantSchema } from './variant-schema' +import type { TanstackAiSqliteSchema } from '../src/index' +import type { DrizzleSqliteDb } from '../src/index' + +function createVariantDb(): { db: DrizzleSqliteDb; sqlite: DatabaseSync } { + const sqlite = new DatabaseSync(':memory:') + for (const statement of variantDdl) sqlite.exec(statement) + const db = drizzle((sql, params, method) => { + const statement = sqlite.prepare(sql) + if (method === 'run') { + statement.run(...params) + return Promise.resolve({ rows: [] }) + } + if (method === 'get') { + const row = statement.get(...params) + return Promise.resolve({ rows: row ? Object.values(row) : [] }) + } + const rows = statement.all(...params) + return Promise.resolve({ rows: rows.map((row) => Object.values(row)) }) + }) + return { db, sqlite } +} + +// The full store contract must hold when the runtime operates over a schema +// whose table and column database names all differ from the bundled ones. +runPersistenceConformance( + 'drizzle-sqlite (injected variant schema)', + () => drizzlePersistence(createVariantDb().db, { schema: variantSchema }), + // This backend has no distributed lock primitive. + { skip: ['locks'] }, +) + +describe('drizzlePersistence with an injected schema', () => { + it('writes through the injected table and column names', async () => { + const { db, sqlite } = createVariantDb() + const persistence = drizzlePersistence(db, { schema: variantSchema }) + + await persistence.stores.messages.saveThread('thread-1', [ + { role: 'user', content: 'hello' }, + ]) + + const row = sqlite + .prepare('SELECT threadId, messagesJson FROM app_ai_messages') + .get() + expect(row?.threadId).toBe('thread-1') + expect(JSON.parse(String(row?.messagesJson))).toEqual([ + { role: 'user', content: 'hello' }, + ]) + }) + + it('coexists with app-owned columns on the same tables', async () => { + const { db, sqlite } = createVariantDb() + const persistence = drizzlePersistence(db, { schema: variantSchema }) + + await persistence.stores.messages.saveThread('thread-owned', [ + { role: 'user', content: 'mine' }, + ]) + // The app claims ownership through its own extension column. + await db + .update(variantSchema.messages) + .set({ userId: 'user-42' }) + .where(eq(variantSchema.messages.threadId, 'thread-owned')) + + // Store updates leave the app-owned column untouched. + await persistence.stores.messages.saveThread('thread-owned', [ + { role: 'user', content: 'mine' }, + { role: 'assistant', content: 'yours' }, + ]) + + const row = sqlite + .prepare('SELECT userId FROM app_ai_messages WHERE threadId = ?') + .get('thread-owned') + expect(row?.userId).toBe('user-42') + expect( + await persistence.stores.messages.loadThread('thread-owned'), + ).toHaveLength(2) + }) + + it('rejects a schema with missing tables or columns', () => { + const { db } = createVariantDb() + + const { metadata: _dropped, ...withoutMetadata } = schema + expect(() => + drizzlePersistence(db, { + schema: withoutMetadata as unknown as TanstackAiSqliteSchema, + }), + ).toThrow(DrizzleSchemaError) + expect(() => + drizzlePersistence(db, { + schema: withoutMetadata as unknown as TanstackAiSqliteSchema, + }), + ).toThrow(/`metadata` is not a Drizzle SQLite table/) + + const incompleteMessages = sqliteTable('messages', { + threadId: text('thread_id').primaryKey(), + }) + expect(() => + drizzlePersistence(db, { + schema: { + ...schema, + messages: incompleteMessages, + } as unknown as TanstackAiSqliteSchema, + }), + ).toThrow(/`messages\.messagesJson` is missing/) + }) +}) diff --git a/packages/ai-persistence-drizzle/tests/drizzle.conformance.test.ts b/packages/ai-persistence-drizzle/tests/drizzle.conformance.test.ts new file mode 100644 index 000000000..7c6e03df4 --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/drizzle.conformance.test.ts @@ -0,0 +1,9 @@ +import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' +import { sqlitePersistence } from '../src/sqlite' + +runPersistenceConformance( + 'drizzle-sqlite', + () => sqlitePersistence({ url: ':memory:', migrate: true }), + // This backend has no distributed lock primitive. + { skip: ['locks'] }, +) diff --git a/packages/ai-persistence-drizzle/tests/migration-cli.test.ts b/packages/ai-persistence-drizzle/tests/migration-cli.test.ts new file mode 100644 index 000000000..2743a2cbb --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/migration-cli.test.ts @@ -0,0 +1,65 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { sqliteMigrations } from '../src/migrations' +import { runDrizzleMigrationsCli } from '../src/migration-cli' + +const temporaryDirectories: Array = [] + +async function createTemporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'tanstack-ai-drizzle-cli-')) + temporaryDirectories.push(directory) + return directory +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ) +}) + +describe('drizzle migrations CLI', () => { + it('prints canonical migration SQL to stdout', async () => { + let output = '' + await runDrizzleMigrationsCli(['--stdout'], { + writeStdout(value) { + output += value + }, + }) + + expect(output).toBe(`${sqliteMigrations[0]?.sql.trimEnd()}\n`) + }) + + it('copies migrations without overwriting divergent files by default', async () => { + const directory = await createTemporaryDirectory() + await runDrizzleMigrationsCli(['--out', directory]) + + const migration = sqliteMigrations[0] + expect(migration).toBeDefined() + if (!migration) return + const destination = join(directory, migration.filename) + expect(await readFile(destination, 'utf8')).toBe(migration.sql) + + await writeFile(destination, 'user-owned contents', 'utf8') + await expect(runDrizzleMigrationsCli(['--out', directory])).rejects.toThrow( + /refusing to overwrite/i, + ) + expect(await readFile(destination, 'utf8')).toBe('user-owned contents') + + await runDrizzleMigrationsCli(['--out', directory, '--force']) + expect(await readFile(destination, 'utf8')).toBe(migration.sql) + }) + + it('fails on ambiguous or incomplete output options', async () => { + const directory = await createTemporaryDirectory() + await expect(runDrizzleMigrationsCli([])).rejects.toThrow( + /--out.*--stdout/i, + ) + await expect( + runDrizzleMigrationsCli(['--stdout', '--out', directory]), + ).rejects.toThrow(/exactly one/i) + }) +}) diff --git a/packages/ai-persistence-drizzle/tests/migrations.test.ts b/packages/ai-persistence-drizzle/tests/migrations.test.ts new file mode 100644 index 000000000..f9a016907 --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/migrations.test.ts @@ -0,0 +1,101 @@ +import { DatabaseSync } from 'node:sqlite' +import { afterEach, describe, expect, it } from 'vitest' +import { sqliteMigrations } from '../src/migrations' +import { applySqliteMigrations } from '../src/sqlite-migrations' + +const databases: Array = [] + +function createDatabase(): DatabaseSync { + const database = new DatabaseSync(':memory:') + databases.push(database) + return database +} + +afterEach(() => { + for (const database of databases.splice(0)) database.close() +}) + +describe('sqlite migrations', () => { + it('exposes an ordered canonical manifest and applies it idempotently', () => { + expect(sqliteMigrations.map((migration) => migration.id)).toEqual([ + '0000_tanstack_ai_initial', + ]) + + const database = createDatabase() + applySqliteMigrations(database, sqliteMigrations) + applySqliteMigrations(database, sqliteMigrations) + + const tableNames = database + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name", + ) + .all() + .map((row) => row.name) + expect(tableNames).toEqual( + expect.arrayContaining([ + '__tanstack_ai_migrations', + 'interrupts', + 'messages', + 'metadata', + 'runs', + ]), + ) + expect( + database + .prepare('SELECT migration_id FROM __tanstack_ai_migrations') + .all(), + ).toEqual([{ migration_id: '0000_tanstack_ai_initial' }]) + }) + + it('rolls back migration SQL and bookkeeping together, then permits retry', () => { + const database = createDatabase() + const failingMigration = { + id: '0000_retry_test', + filename: '0000_retry_test.sql', + sql: ` + CREATE TABLE retry_target (id INTEGER PRIMARY KEY); + CREATE TRIGGER reject_migration_bookkeeping + BEFORE INSERT ON __tanstack_ai_migrations + BEGIN + SELECT RAISE(ABORT, 'bookkeeping failed'); + END; + `, + } + + expect(() => applySqliteMigrations(database, [failingMigration])).toThrow( + 'bookkeeping failed', + ) + expect( + database + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'retry_target'", + ) + .get(), + ).toBeUndefined() + expect( + database + .prepare('SELECT migration_id FROM __tanstack_ai_migrations') + .all(), + ).toEqual([]) + + applySqliteMigrations(database, [ + { + ...failingMigration, + sql: 'CREATE TABLE retry_target (id INTEGER PRIMARY KEY);', + }, + ]) + + expect( + database + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'retry_target'", + ) + .get(), + ).toEqual({ name: 'retry_target' }) + expect( + database + .prepare('SELECT migration_id FROM __tanstack_ai_migrations') + .all(), + ).toEqual([{ migration_id: '0000_retry_test' }]) + }) +}) diff --git a/packages/ai-persistence-drizzle/tests/package-contract.test.ts b/packages/ai-persistence-drizzle/tests/package-contract.test.ts new file mode 100644 index 000000000..dfcdb7a6b --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/package-contract.test.ts @@ -0,0 +1,67 @@ +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import packageJson from '../package.json' + +describe('drizzle package contract', () => { + it('publishes the edge root, Node sqlite subpath, CLI, and migration assets', () => { + expect(packageJson.exports).toEqual({ + '.': { + types: './dist/esm/index.d.ts', + import: './dist/esm/index.js', + }, + './sqlite': { + types: './dist/esm/sqlite.d.ts', + import: './dist/esm/sqlite.js', + }, + }) + expect(packageJson.bin).toEqual({ + 'tanstack-ai-drizzle-migrations': + './bin/tanstack-ai-drizzle-migrations.mjs', + 'tanstack-ai-drizzle-schema': './bin/tanstack-ai-drizzle-schema.mjs', + }) + expect(packageJson.files).toEqual( + expect.arrayContaining(['bin', 'dist', 'src', 'drizzle']), + ) + }) + + it('keeps Node built-ins and Buffer out of the root import graph', async () => { + const rootFiles = [ + 'index.ts', + 'migrations.ts', + 'schema.ts', + 'schema-contract.ts', + 'schema-source.ts', + 'stores.ts', + ] + for (const filename of rootFiles) { + const contents = await readFile( + fileURLToPath(new URL(`../src/${filename}`, import.meta.url)), + 'utf8', + ) + expect(contents, filename).not.toMatch(/from ['"]node:/) + expect(contents, filename).not.toMatch(/\bBuffer\b/) + } + const root = await readFile( + fileURLToPath(new URL('../src/index.ts', import.meta.url)), + 'utf8', + ) + expect(root).not.toMatch(/from ['"].*sqlite/) + }) + + it('keeps the shipped drizzle-kit migration equal to the embedded asset', async () => { + const embedded = await readFile( + fileURLToPath( + new URL('../src/assets/0000_tanstack_ai_initial.sql', import.meta.url), + ), + 'utf8', + ) + const shipped = await readFile( + fileURLToPath( + new URL('../drizzle/0000_tanstack_ai_initial.sql', import.meta.url), + ), + 'utf8', + ) + expect(shipped).toBe(embedded) + }) +}) diff --git a/packages/ai-persistence-drizzle/tests/schema-cli.test.ts b/packages/ai-persistence-drizzle/tests/schema-cli.test.ts new file mode 100644 index 000000000..c24afee87 --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/schema-cli.test.ts @@ -0,0 +1,67 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { drizzleSchemaFilename, drizzleSchemaSource } from '../src/index' +import { runDrizzleSchemaCli } from '../src/schema-cli' + +const temporaryDirectories: Array = [] + +async function createTemporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'tanstack-ai-drizzle-schema-')) + temporaryDirectories.push(directory) + return directory +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ) +}) + +describe('Drizzle schema CLI', () => { + it('prints the schema module to stdout', async () => { + let output = '' + await runDrizzleSchemaCli(['--stdout'], { + writeStdout(value) { + output += value + }, + }) + expect(output).toBe(`${drizzleSchemaSource.trimEnd()}\n`) + expect(output).toContain('sqliteTable') + expect(output).toContain('drizzlePersistence(db, { schema })') + }) + + it('copies the schema without overwriting divergent files by default', async () => { + const directory = await createTemporaryDirectory() + await runDrizzleSchemaCli(['--out', directory]) + + const destination = join(directory, drizzleSchemaFilename) + expect(await readFile(destination, 'utf8')).toBe(drizzleSchemaSource) + + // Re-running against an identical file is a no-op. + await runDrizzleSchemaCli(['--out', directory]) + + await writeFile(destination, 'user-owned contents', 'utf8') + await expect(runDrizzleSchemaCli(['--out', directory])).rejects.toThrow( + /refusing to overwrite/i, + ) + expect(await readFile(destination, 'utf8')).toBe('user-owned contents') + + await runDrizzleSchemaCli(['--out', directory, '--force']) + expect(await readFile(destination, 'utf8')).toBe(drizzleSchemaSource) + }) + + it('fails on ambiguous or incomplete output options', async () => { + const directory = await createTemporaryDirectory() + await expect(runDrizzleSchemaCli([])).rejects.toThrow(/--out.*--stdout/i) + await expect( + runDrizzleSchemaCli(['--stdout', '--out', directory]), + ).rejects.toThrow(/exactly one/i) + await expect(runDrizzleSchemaCli(['--stdout', '--force'])).rejects.toThrow( + /--force can only be used with --out/i, + ) + }) +}) diff --git a/packages/ai-persistence-drizzle/tests/schema-source.test.ts b/packages/ai-persistence-drizzle/tests/schema-source.test.ts new file mode 100644 index 000000000..f72716a3f --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/schema-source.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { getTableConfig } from 'drizzle-orm/sqlite-core' +import { schema as bundled } from '../src/schema' +import { schema as emitted } from '../src/assets/tanstack-ai-schema' +import type { SQLiteTable } from 'drizzle-orm/sqlite-core' + +function normalize(table: SQLiteTable) { + const config = getTableConfig(table) + return { + name: config.name, + columns: config.columns.map((column) => ({ + name: column.name, + sqlType: column.getSQLType(), + notNull: column.notNull, + primary: column.primary, + })), + primaryKeys: config.primaryKeys.map((primaryKey) => + primaryKey.columns.map((column) => column.name), + ), + } +} + +describe('emitted schema asset', () => { + it('is structurally identical to the bundled schema', () => { + const tableKeys = Object.keys(bundled) as Array + expect(Object.keys(emitted)).toEqual(Object.keys(bundled)) + for (const key of tableKeys) { + expect(normalize(emitted[key]), key).toEqual(normalize(bundled[key])) + } + }) +}) diff --git a/packages/ai-persistence-drizzle/tests/sqlite.test.ts b/packages/ai-persistence-drizzle/tests/sqlite.test.ts new file mode 100644 index 000000000..299081cc0 --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/sqlite.test.ts @@ -0,0 +1,53 @@ +import { mkdtemp, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { sqlitePersistence } from '../src/sqlite' + +const temporaryDirectories: Array = [] + +afterEach(async () => { + for (const directory of temporaryDirectories.splice(0)) { + await rm(directory, { recursive: true, force: true }) + } +}) + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'tanstack-ai-sqlite-')) + temporaryDirectories.push(directory) + return directory +} + +describe('sqlitePersistence', () => { + it('creates a missing parent directory for a filesystem database', async () => { + const root = await temporaryDirectory() + const databasePath = join(root, 'nested', 'state.sqlite') + const persistence = sqlitePersistence({ + url: `file:${databasePath}`, + migrate: true, + }) + + try { + await expect(stat(databasePath)).resolves.toMatchObject({ + isFile: expect.any(Function), + }) + } finally { + persistence.close() + } + }) + + it('exposes an idempotent close for the database handle it owns', () => { + const persistence = sqlitePersistence({ url: ':memory:' }) + + expect(() => { + persistence.close() + persistence.close() + }).not.toThrow() + }) + + it('rejects non-file URI schemes before touching the filesystem', () => { + expect(() => + sqlitePersistence({ url: 'https://example.test/state.sqlite' }), + ).toThrow(/unsupported SQLite URL/i) + }) +}) diff --git a/packages/ai-persistence-drizzle/tests/store-behavior.test.ts b/packages/ai-persistence-drizzle/tests/store-behavior.test.ts new file mode 100644 index 000000000..f5e680a9b --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/store-behavior.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { sqlitePersistence } from '../src/sqlite' + +type Persistence = ReturnType + +const open: Array = [] + +function persistence(): Persistence { + const created = sqlitePersistence({ url: ':memory:', migrate: true }) + open.push(created) + return created +} + +afterEach(() => { + for (const created of open.splice(0)) created.close() +}) + +describe('drizzle metadata nullish values', () => { + it('rejects nullish values with a clear error instead of a NOT NULL crash', async () => { + const { stores } = persistence() + + // A NOT NULL json column cannot store nullish; surface a clear, actionable + // error (consistent with the Prisma backend) rather than a driver crash. + await expect(stores.metadata.set('scope', 'u', undefined)).rejects.toThrow( + /metadata values must be defined/, + ) + await expect(stores.metadata.set('scope', 'n', null)).rejects.toThrow( + /metadata values must be defined/, + ) + expect(await stores.metadata.get('scope', 'u')).toBeNull() + + // Falsy-but-defined values are stored and round-trip. + await stores.metadata.set('scope', 'zero', 0) + expect(await stores.metadata.get('scope', 'zero')).toBe(0) + await stores.metadata.set('scope', 'empty', '') + expect(await stores.metadata.get('scope', 'empty')).toBe('') + await stores.metadata.set('scope', 'obj', { a: 1 }) + expect(await stores.metadata.get('scope', 'obj')).toEqual({ a: 1 }) + }) +}) diff --git a/packages/ai-persistence-drizzle/tests/variant-schema.ts b/packages/ai-persistence-drizzle/tests/variant-schema.ts new file mode 100644 index 000000000..46551464d --- /dev/null +++ b/packages/ai-persistence-drizzle/tests/variant-schema.ts @@ -0,0 +1,93 @@ +/** + * A deliberately renamed copy of the TanStack AI schema for exercising + * `drizzlePersistence(db, { schema })`: prefixed table names, camelCase column + * names (as a project using drizzle `casing`-style conventions would have), + * and an extra app-owned `userId` ownership column on the messages table. + * Structure (data shapes, nullability, primary keys) matches the contract. + */ +import { integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import type { InterruptRecord, RunStatus } from '@tanstack/ai-persistence' +import type { ModelMessage, TokenUsage } from '@tanstack/ai' + +const messages = sqliteTable('app_ai_messages', { + threadId: text('threadId').primaryKey(), + messagesJson: text('messagesJson', { mode: 'json' }) + .$type>() + .notNull(), + // App-owned extension the TanStack AI stores never read or write. + userId: text('userId'), +}) + +const runs = sqliteTable('app_ai_runs', { + runId: text('runId').primaryKey(), + threadId: text('threadId').notNull(), + status: text('status').$type().notNull(), + startedAt: integer('startedAt').notNull(), + finishedAt: integer('finishedAt'), + error: text('errorText'), + usageJson: text('usageJson', { mode: 'json' }).$type(), +}) + +const interrupts = sqliteTable('app_ai_interrupts', { + interruptId: text('interruptId').primaryKey(), + runId: text('runId').notNull(), + threadId: text('threadId').notNull(), + status: text('status').$type().notNull(), + requestedAt: integer('requestedAt').notNull(), + resolvedAt: integer('resolvedAt'), + payloadJson: text('payloadJson', { mode: 'json' }) + .$type>() + .notNull(), + responseJson: text('responseJson', { mode: 'json' }).$type(), +}) + +const metadata = sqliteTable( + 'app_ai_metadata', + { + scope: text('scopeName').notNull(), + key: text('keyName').notNull(), + valueJson: text('valueJson', { mode: 'json' }).$type().notNull(), + }, + (table) => [primaryKey({ columns: [table.scope, table.key] })], +) + +export const variantSchema = { + messages, + runs, + interrupts, + metadata, +} + +/** DDL matching {@link variantSchema}, applied directly in tests. */ +export const variantDdl: ReadonlyArray = [ + `CREATE TABLE app_ai_messages ( + threadId text PRIMARY KEY NOT NULL, + messagesJson text NOT NULL, + userId text + );`, + `CREATE TABLE app_ai_runs ( + runId text PRIMARY KEY NOT NULL, + threadId text NOT NULL, + status text NOT NULL, + startedAt integer NOT NULL, + finishedAt integer, + errorText text, + usageJson text + );`, + `CREATE TABLE app_ai_interrupts ( + interruptId text PRIMARY KEY NOT NULL, + runId text NOT NULL, + threadId text NOT NULL, + status text NOT NULL, + requestedAt integer NOT NULL, + resolvedAt integer, + payloadJson text NOT NULL, + responseJson text + );`, + `CREATE TABLE app_ai_metadata ( + scopeName text NOT NULL, + keyName text NOT NULL, + valueJson text NOT NULL, + PRIMARY KEY(scopeName, keyName) + );`, +] diff --git a/packages/ai-persistence-drizzle/tsconfig.json b/packages/ai-persistence-drizzle/tsconfig.json new file mode 100644 index 000000000..c38689f4e --- /dev/null +++ b/packages/ai-persistence-drizzle/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ai-persistence-drizzle/vite.config.ts b/packages/ai-persistence-drizzle/vite.config.ts new file mode 100644 index 000000000..05551030c --- /dev/null +++ b/packages/ai-persistence-drizzle/vite.config.ts @@ -0,0 +1,42 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + '**/types.ts', + ], + include: ['src/**/*.ts'], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: [ + './src/index.ts', + './src/sqlite.ts', + './src/cli.ts', + './src/schema-cli-main.ts', + ], + srcDir: './src', + cjs: false, + }), +) diff --git a/packages/ai-persistence-prisma/.gitignore b/packages/ai-persistence-prisma/.gitignore new file mode 100644 index 000000000..e1c30d225 --- /dev/null +++ b/packages/ai-persistence-prisma/.gitignore @@ -0,0 +1,3 @@ +# Scratch sqlite databases used to generate migrations / run tests. +prisma/*.db +prisma/*.db-journal diff --git a/packages/ai-persistence-prisma/bin/tanstack-ai-prisma-models.mjs b/packages/ai-persistence-prisma/bin/tanstack-ai-prisma-models.mjs new file mode 100644 index 000000000..0a4de6588 --- /dev/null +++ b/packages/ai-persistence-prisma/bin/tanstack-ai-prisma-models.mjs @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import '../dist/esm/cli.js' diff --git a/packages/ai-persistence-prisma/package.json b/packages/ai-persistence-prisma/package.json new file mode 100644 index 000000000..9e6b5225f --- /dev/null +++ b/packages/ai-persistence-prisma/package.json @@ -0,0 +1,91 @@ +{ + "name": "@tanstack/ai-persistence-prisma", + "version": "0.0.0", + "description": "Prisma persistence for TanStack AI with a provider-neutral models fragment and bring-your-own migrated PrismaClient.", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-persistence-prisma" + }, + "keywords": [ + "ai", + "tanstack", + "persistence", + "prisma", + "database" + ], + "type": "module", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + } + }, + "bin": { + "tanstack-ai-prisma-models": "./bin/tanstack-ai-prisma-models.mjs" + }, + "files": [ + "bin", + "dist", + "src", + "prisma/tanstack-ai.prisma" + ], + "scripts": { + "build": "vite build", + "clean": "premove ./build ./dist", + "db:generate": "prisma generate --schema ./prisma", + "lint:fix": "oxlint src --type-aware --fix", + "test:build": "publint --strict", + "test:lib": "vitest", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc", + "test:oxlint": "oxlint src --type-aware" + }, + "nx": { + "targets": { + "db:generate": { + "cache": false + }, + "build": { + "dependsOn": [ + "db:generate", + "^build" + ] + }, + "test:lib": { + "dependsOn": [ + "db:generate", + "^build" + ] + }, + "test:types": { + "dependsOn": [ + "db:generate", + "^build" + ] + }, + "test:eslint": { + "dependsOn": [ + "db:generate", + "^build" + ] + } + } + }, + "peerDependencies": { + "@prisma/client": ">=6.7.0", + "@tanstack/ai": "workspace:^", + "@tanstack/ai-persistence": "workspace:^" + }, + "devDependencies": { + "@prisma/client": "^6.19.3", + "@tanstack/ai": "workspace:*", + "@tanstack/ai-persistence": "workspace:*", + "@vitest/coverage-v8": "4.0.14", + "prisma": "^6.19.3" + } +} diff --git a/packages/ai-persistence-prisma/prisma/schema.prisma b/packages/ai-persistence-prisma/prisma/schema.prisma new file mode 100644 index 000000000..3506e60fb --- /dev/null +++ b/packages/ai-persistence-prisma/prisma/schema.prisma @@ -0,0 +1,10 @@ +// Internal test-client configuration. The models live in the provider-neutral +// sibling fragment that is shipped to package consumers. +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} diff --git a/packages/ai-persistence-prisma/prisma/tanstack-ai.prisma b/packages/ai-persistence-prisma/prisma/tanstack-ai.prisma new file mode 100644 index 000000000..aa716415b --- /dev/null +++ b/packages/ai-persistence-prisma/prisma/tanstack-ai.prisma @@ -0,0 +1,50 @@ +// Provider-neutral TanStack AI persistence models. +// +// Copy this file into a Prisma multi-file schema directory, then create and +// apply migrations using the application's selected provider and normal Prisma +// workflow. This fragment intentionally contains no datasource or generator. + +/// Thread message history (`MessageStore`). +model Message { + threadId String @id @map("thread_id") + messagesJson String @map("messages_json") + + @@map("messages") +} + +/// Run lifecycle records (`RunStore`). +model Run { + runId String @id @map("run_id") + threadId String @map("thread_id") + status String + startedAt BigInt @map("started_at") + finishedAt BigInt? @map("finished_at") + error String? + usageJson String? @map("usage_json") + + @@map("runs") +} + +/// Interrupt / approval records (`InterruptStore`). +model Interrupt { + interruptId String @id @map("interrupt_id") + runId String @map("run_id") + threadId String @map("thread_id") + status String + requestedAt BigInt @map("requested_at") + resolvedAt BigInt? @map("resolved_at") + payloadJson String @map("payload_json") + responseJson String? @map("response_json") + + @@map("interrupts") +} + +/// Scoped key/value metadata (`MetadataStore`). +model Metadata { + scope String + key String + valueJson String @map("value_json") + + @@id([scope, key]) + @@map("metadata") +} diff --git a/packages/ai-persistence-prisma/src/assets.d.ts b/packages/ai-persistence-prisma/src/assets.d.ts new file mode 100644 index 000000000..33c9a3e00 --- /dev/null +++ b/packages/ai-persistence-prisma/src/assets.d.ts @@ -0,0 +1,4 @@ +declare module '*.prisma?raw' { + const contents: string + export default contents +} diff --git a/packages/ai-persistence-prisma/src/assets/tanstack-ai.prisma b/packages/ai-persistence-prisma/src/assets/tanstack-ai.prisma new file mode 100644 index 000000000..aa716415b --- /dev/null +++ b/packages/ai-persistence-prisma/src/assets/tanstack-ai.prisma @@ -0,0 +1,50 @@ +// Provider-neutral TanStack AI persistence models. +// +// Copy this file into a Prisma multi-file schema directory, then create and +// apply migrations using the application's selected provider and normal Prisma +// workflow. This fragment intentionally contains no datasource or generator. + +/// Thread message history (`MessageStore`). +model Message { + threadId String @id @map("thread_id") + messagesJson String @map("messages_json") + + @@map("messages") +} + +/// Run lifecycle records (`RunStore`). +model Run { + runId String @id @map("run_id") + threadId String @map("thread_id") + status String + startedAt BigInt @map("started_at") + finishedAt BigInt? @map("finished_at") + error String? + usageJson String? @map("usage_json") + + @@map("runs") +} + +/// Interrupt / approval records (`InterruptStore`). +model Interrupt { + interruptId String @id @map("interrupt_id") + runId String @map("run_id") + threadId String @map("thread_id") + status String + requestedAt BigInt @map("requested_at") + resolvedAt BigInt? @map("resolved_at") + payloadJson String @map("payload_json") + responseJson String? @map("response_json") + + @@map("interrupts") +} + +/// Scoped key/value metadata (`MetadataStore`). +model Metadata { + scope String + key String + valueJson String @map("value_json") + + @@id([scope, key]) + @@map("metadata") +} diff --git a/packages/ai-persistence-prisma/src/cli.ts b/packages/ai-persistence-prisma/src/cli.ts new file mode 100644 index 000000000..21273f6e3 --- /dev/null +++ b/packages/ai-persistence-prisma/src/cli.ts @@ -0,0 +1,10 @@ +#!/usr/bin/env node +import { runPrismaModelsCli } from './models-cli' + +try { + await runPrismaModelsCli(process.argv.slice(2)) +} catch (error) { + const message = error instanceof Error ? error.message : String(error) + process.stderr.write(`${message}\n`) + process.exitCode = 1 +} diff --git a/packages/ai-persistence-prisma/src/index.ts b/packages/ai-persistence-prisma/src/index.ts new file mode 100644 index 000000000..3250f5920 --- /dev/null +++ b/packages/ai-persistence-prisma/src/index.ts @@ -0,0 +1,57 @@ +/** + * Prisma-backed state persistence for TanStack AI. + * + * Add the provider-neutral {@link prismaModels} fragment to a Prisma multi-file + * schema, run Prisma's normal migration workflow for your selected provider, + * then pass the generated client to {@link prismaPersistence}. + */ +import { resolveDelegates } from './model-contract' +import { + createInterruptStore, + createMessageStore, + createMetadataStore, + createRunStore, +} from './stores' +import type { PrismaModelMap } from './model-contract' +import type { PrismaClient } from '@prisma/client' + +export { prismaModels, prismaModelsFilename } from './models' +export { PrismaModelError } from './model-contract' +export type { PrismaModelMap } from './model-contract' + +export interface PrismaPersistenceOptions { + /** + * Rename the TanStack AI models in your copy of the fragment — for example + * to avoid a collision with an existing `Message` or `Run` model — and map + * each store to the renamed client delegate here. Values are the camelCase + * client accessor names: `model ChatMessage` → `{ messages: 'chatMessage' }`. + * Keep the field names and types from the fragment; database table and + * column names are already yours via `@@map` / `@map`. + */ + models?: PrismaModelMap +} + +/** + * Wire TanStack AI persistence stores over a migrated Prisma client. + * + * No `locks` store is returned: this backend has no distributed lock primitive, + * and bundling an `InMemoryLockStore` would silently hand multi-instance + * deployments a lock that does not lock across instances. Consumers that need a + * lock (e.g. `withSandbox`) transparently fall back to an in-process + * `InMemoryLockStore`; for cross-instance locking use a distributed backend such + * as the Cloudflare Durable Object lock (`@tanstack/ai-persistence-cloudflare`). + */ +export function prismaPersistence( + prisma: PrismaClient, + options?: PrismaPersistenceOptions, +) { + const delegates = resolveDelegates(prisma, options?.models) + return { + stores: { + messages: createMessageStore(delegates), + runs: createRunStore(delegates), + interrupts: createInterruptStore(delegates), + metadata: createMetadataStore(delegates), + }, + } +} diff --git a/packages/ai-persistence-prisma/src/model-contract.ts b/packages/ai-persistence-prisma/src/model-contract.ts new file mode 100644 index 000000000..d2c9d618f --- /dev/null +++ b/packages/ai-persistence-prisma/src/model-contract.ts @@ -0,0 +1,217 @@ +/** + * Structural contract for the Prisma model delegates the stores operate over. + * + * `prismaPersistence` accepts a model-name map so applications can rename the + * TanStack AI models in their own copy of the fragment — for example to avoid + * collisions with an existing `Message` or `Run` model — and point the runtime + * at the renamed delegates (`prismaPersistence(prisma, { models })`). + * + * What stays fixed is the **client-level field surface**: field names + * (`threadId`, `messagesJson`, …), their types, and the default composite + * unique alias `scope_key` on the metadata model. Database table and column + * names are already yours via `@@map` / `@map` in the fragment, and extra + * app-owned fields are ignored by the stores (keep them optional or defaulted + * so creates succeed). + * + * The delegate interfaces below are the minimal structural slice of the + * generated client the stores call; the generated delegates satisfy them + * (asserted in the package's type tests). + */ + +export interface MessageRow { + threadId: string + messagesJson: string +} + +export interface RunRow { + runId: string + threadId: string + status: string + startedAt: bigint + finishedAt: bigint | null + error: string | null + usageJson: string | null +} + +export interface InterruptRow { + interruptId: string + runId: string + threadId: string + status: string + requestedAt: bigint + resolvedAt: bigint | null + payloadJson: string + responseJson: string | null +} + +export interface MetadataRow { + scope: string + key: string + valueJson: string +} + +export interface MessageDelegate { + findUnique: (args: { + where: { threadId: string } + }) => Promise + upsert: (args: { + where: { threadId: string } + create: { threadId: string; messagesJson: string } + update: { messagesJson: string } + }) => Promise +} + +export interface RunDelegate { + findUnique: (args: { where: { runId: string } }) => Promise + upsert: (args: { + where: { runId: string } + create: { + runId: string + threadId: string + status: string + startedAt: bigint + } + update: Record + }) => Promise + updateMany: (args: { + where: { runId: string } + data: { + status?: string + finishedAt?: bigint + error?: string + usageJson?: string + } + }) => Promise +} + +export interface InterruptDelegate { + findUnique: (args: { + where: { interruptId: string } + }) => Promise + findMany: (args: { + where: { threadId?: string; runId?: string; status?: string } + orderBy: { requestedAt: 'asc' } + }) => Promise> + upsert: (args: { + where: { interruptId: string } + create: { + interruptId: string + runId: string + threadId: string + status: string + requestedAt: bigint + payloadJson: string + responseJson: string | null + } + update: Record + }) => Promise + updateMany: (args: { + where: { interruptId: string } + data: { status: string; resolvedAt: bigint; responseJson?: string | null } + }) => Promise +} + +export interface MetadataDelegate { + findUnique: (args: { + where: { scope_key: { scope: string; key: string } } + }) => Promise + upsert: (args: { + where: { scope_key: { scope: string; key: string } } + create: { scope: string; key: string; valueJson: string } + update: { valueJson: string } + }) => Promise + deleteMany: (args: { + where: { scope: string; key: string } + }) => Promise +} + +/** The delegate set the stores operate over. */ +export interface TanstackAiDelegates { + message: MessageDelegate + run: RunDelegate + interrupt: InterruptDelegate + metadata: MetadataDelegate +} + +/** + * Store key → Prisma client delegate property. Values are the camelCase + * client accessor names (`prisma.`), not the PascalCase model names: + * a `model ChatMessage` is reached as `chatMessage`. + */ +export interface PrismaModelMap { + messages?: string + runs?: string + interrupts?: string + metadata?: string +} + +const defaultModelNames: Required = { + messages: 'message', + runs: 'run', + interrupts: 'interrupt', + metadata: 'metadata', +} + +const storeKeys = Object.keys(defaultModelNames) as Array< + keyof Required +> + +const delegateKeyByStoreKey: { + [K in keyof Required]: keyof TanstackAiDelegates +} = { + messages: 'message', + runs: 'run', + interrupts: 'interrupt', + metadata: 'metadata', +} + +/** A model map pointed `prismaPersistence` at missing or invalid delegates. */ +export class PrismaModelError extends Error { + constructor(problems: ReadonlyArray) { + super( + `Invalid TanStack AI Prisma model mapping:\n${problems + .map((problem) => ` - ${problem}`) + .join('\n')}`, + ) + this.name = 'PrismaModelError' + } +} + +function isDelegate(value: unknown): boolean { + return ( + value !== null && + typeof value === 'object' && + typeof Reflect.get(value, 'findUnique') === 'function' && + typeof Reflect.get(value, 'upsert') === 'function' + ) +} + +/** + * Resolve the four store delegates off the client, applying any model-name + * overrides and verifying each resolved delegate looks like a Prisma model + * delegate. Field shapes are enforced at compile time by the delegate + * interfaces and are not re-checked here. + */ +export function resolveDelegates( + client: object, + models?: PrismaModelMap, +): TanstackAiDelegates { + const problems: Array = [] + const resolved: Partial> = {} + for (const storeKey of storeKeys) { + const modelName = models?.[storeKey] ?? defaultModelNames[storeKey] + const candidate: unknown = Reflect.get(client, modelName) + if (!isDelegate(candidate)) { + problems.push( + `\`${storeKey}\` maps to \`client.${modelName}\`, which is not a Prisma model delegate.`, + ) + continue + } + resolved[delegateKeyByStoreKey[storeKey]] = candidate + } + if (problems.length > 0) throw new PrismaModelError(problems) + // Safe narrowing: presence and delegate shape were verified above; the + // per-method argument/result types are pinned by the delegate interfaces, + // which the generated client's delegates satisfy (asserted in type tests). + return resolved as TanstackAiDelegates +} diff --git a/packages/ai-persistence-prisma/src/models-cli.ts b/packages/ai-persistence-prisma/src/models-cli.ts new file mode 100644 index 000000000..42410dd8e --- /dev/null +++ b/packages/ai-persistence-prisma/src/models-cli.ts @@ -0,0 +1,121 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { prismaModels, prismaModelsFilename } from './models' + +export interface ModelsCliOutput { + writeStdout: (value: string) => void +} + +export class ModelsCliError extends Error { + constructor(message: string) { + super(message) + this.name = 'ModelsCliError' + } +} + +const usage = `Usage: tanstack-ai-prisma-models (--out | --stdout) [--force] + +Options: + --out Copy the models-only Prisma schema fragment. + --stdout Print the models-only Prisma schema fragment. + --force Replace a divergent fragment when copying. + --help Show this help. +` + +interface ParsedArguments { + force: boolean + help: boolean + outDirectory?: string + stdout: boolean +} + +function parseArguments(args: ReadonlyArray): ParsedArguments { + let force = false + let help = false + let outDirectory: string | undefined + let stdout = false + + for (let index = 0; index < args.length; index++) { + const argument = args[index] + if (argument === '--force') { + force = true + } else if (argument === '--help' || argument === '-h') { + help = true + } else if (argument === '--stdout') { + stdout = true + } else if (argument === '--out') { + const value = args[index + 1] + if (!value || value.startsWith('-')) { + throw new ModelsCliError('--out requires a directory.') + } + if (outDirectory !== undefined) { + throw new ModelsCliError('--out may only be provided once.') + } + outDirectory = value + index++ + } else { + throw new ModelsCliError(`Unknown argument: ${argument ?? ''}`) + } + } + + return { force, help, outDirectory, stdout } +} + +function isMissingFileError(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +async function readExistingFile(path: string): Promise { + try { + return await readFile(path, 'utf8') + } catch (error) { + if (isMissingFileError(error)) return undefined + throw error + } +} + +/** Execute the models asset CLI. Exported for deterministic CLI tests. */ +export async function runPrismaModelsCli( + args: ReadonlyArray, + output: ModelsCliOutput = { + writeStdout(value) { + process.stdout.write(value) + }, + }, +): Promise { + const parsed = parseArguments(args) + if (parsed.help) { + output.writeStdout(usage) + return + } + + const outputCount = + Number(parsed.stdout) + Number(parsed.outDirectory != null) + if (outputCount !== 1) { + throw new ModelsCliError( + 'Provide exactly one output mode: --out or --stdout.', + ) + } + if (parsed.stdout) { + if (parsed.force) { + throw new ModelsCliError('--force can only be used with --out.') + } + output.writeStdout(`${prismaModels.trimEnd()}\n`) + return + } + + const outDirectory = parsed.outDirectory + if (!outDirectory) { + throw new ModelsCliError('--out requires a directory.') + } + await mkdir(outDirectory, { recursive: true }) + const destination = join(outDirectory, prismaModelsFilename) + const existing = await readExistingFile(destination) + if (existing === prismaModels) return + if (existing !== undefined && !parsed.force) { + throw new ModelsCliError( + `Refusing to overwrite divergent Prisma models file: ${destination}. Re-run with --force to replace it.`, + ) + } + await writeFile(destination, prismaModels, 'utf8') +} diff --git a/packages/ai-persistence-prisma/src/models.ts b/packages/ai-persistence-prisma/src/models.ts new file mode 100644 index 000000000..6e22bb77b --- /dev/null +++ b/packages/ai-persistence-prisma/src/models.ts @@ -0,0 +1,7 @@ +import models from './assets/tanstack-ai.prisma?raw' + +/** Filename used by the Prisma models copy CLI. */ +export const prismaModelsFilename = 'tanstack-ai.prisma' + +/** Provider-neutral, models-only Prisma schema fragment. */ +export const prismaModels: string = models diff --git a/packages/ai-persistence-prisma/src/stores.ts b/packages/ai-persistence-prisma/src/stores.ts new file mode 100644 index 000000000..0089ae0eb --- /dev/null +++ b/packages/ai-persistence-prisma/src/stores.ts @@ -0,0 +1,237 @@ +/** + * AIPersistence store implementations over a Prisma `PrismaClient`. + * + * Each method mirrors the reference in-memory backend + * (`@tanstack/ai-persistence`'s `memory.ts`) and the sibling Drizzle backend + * (`@tanstack/ai-persistence-drizzle`'s `stores.ts`), including the + * insert-if-absent `InterruptStore.create` and `RunStore.createOrResume` + * semantics (`upsert` with an empty `update`). JSON-valued columns use + * provider-neutral Prisma `String` fields, so they are serialized with + * `JSON.stringify`/`JSON.parse` here. + */ +import type { + InterruptRow, + RunRow, + TanstackAiDelegates, +} from './model-contract' +import type { ModelMessage } from '@tanstack/ai' +import type { + InterruptRecord, + InterruptStore, + MessageStore, + MetadataStore, + RunRecord, + RunStatus, + RunStore, +} from '@tanstack/ai-persistence' + +export function createMessageStore({ + message, +}: TanstackAiDelegates): MessageStore { + return { + async loadThread(threadId) { + const row = await message.findUnique({ where: { threadId } }) + if (!row) return [] + return JSON.parse(row.messagesJson) as Array + }, + async saveThread(threadId, msgs: Array) { + const messagesJson = JSON.stringify(msgs) + await message.upsert({ + where: { threadId }, + create: { threadId, messagesJson }, + update: { messagesJson }, + }) + }, + } +} + +function mapRun(row: RunRow): RunRecord { + return { + runId: row.runId, + threadId: row.threadId, + status: row.status as RunStatus, + startedAt: Number(row.startedAt), + ...(row.finishedAt != null ? { finishedAt: Number(row.finishedAt) } : {}), + ...(row.error != null ? { error: row.error } : {}), + ...(row.usageJson != null + ? { usage: JSON.parse(row.usageJson) as RunRecord['usage'] } + : {}), + } +} + +export function createRunStore({ run }: TanstackAiDelegates): RunStore { + const store: RunStore = { + async createOrResume(input) { + const existing = await store.get(input.runId) + if (existing) return existing + const record: RunRecord = { + runId: input.runId, + threadId: input.threadId, + status: input.status ?? 'running', + startedAt: input.startedAt, + } + // Upsert with an empty update = insert-if-absent (never overwrites an + // existing run), matching the reference createOrResume semantics. + await run.upsert({ + where: { runId: record.runId }, + create: { + runId: record.runId, + threadId: record.threadId, + status: record.status, + startedAt: BigInt(record.startedAt), + }, + update: {}, + }) + return (await store.get(input.runId)) ?? record + }, + async update(runId, patch) { + const data: { + status?: RunStatus + finishedAt?: bigint + error?: string + usageJson?: string + } = {} + if (patch.status !== undefined) data.status = patch.status + if (patch.finishedAt !== undefined) + data.finishedAt = BigInt(patch.finishedAt) + if (patch.error !== undefined) data.error = patch.error + if (patch.usage !== undefined) + data.usageJson = JSON.stringify(patch.usage) + if (Object.keys(data).length === 0) return + // updateMany no-ops (does not throw) when the run does not exist. + await run.updateMany({ where: { runId }, data }) + }, + async get(runId) { + const row = await run.findUnique({ where: { runId } }) + return row ? mapRun(row) : null + }, + } + return store +} + +function mapInterrupt(row: InterruptRow): InterruptRecord { + return { + interruptId: row.interruptId, + runId: row.runId, + threadId: row.threadId, + status: row.status as InterruptRecord['status'], + requestedAt: Number(row.requestedAt), + ...(row.resolvedAt != null ? { resolvedAt: Number(row.resolvedAt) } : {}), + payload: JSON.parse(row.payloadJson) as Record, + ...(row.responseJson != null + ? { response: JSON.parse(row.responseJson) as unknown } + : {}), + } +} + +export function createInterruptStore({ + interrupt, +}: TanstackAiDelegates): InterruptStore { + return { + async create(record) { + await interrupt.upsert({ + where: { interruptId: record.interruptId }, + create: { + interruptId: record.interruptId, + runId: record.runId, + threadId: record.threadId, + status: 'pending', + requestedAt: BigInt(record.requestedAt), + payloadJson: JSON.stringify(record.payload), + responseJson: + record.response == null ? null : JSON.stringify(record.response), + }, + update: {}, + }) + }, + async resolve(interruptId, response) { + await interrupt.updateMany({ + where: { interruptId }, + data: { + status: 'resolved', + resolvedAt: BigInt(Date.now()), + responseJson: response == null ? null : JSON.stringify(response), + }, + }) + }, + async cancel(interruptId) { + await interrupt.updateMany({ + where: { interruptId }, + data: { status: 'cancelled', resolvedAt: BigInt(Date.now()) }, + }) + }, + async get(interruptId) { + const row = await interrupt.findUnique({ where: { interruptId } }) + return row ? mapInterrupt(row) : null + }, + async list(threadId) { + const rows = await interrupt.findMany({ + where: { threadId }, + orderBy: { requestedAt: 'asc' }, + }) + return rows.map(mapInterrupt) + }, + async listPending(threadId) { + const rows = await interrupt.findMany({ + where: { threadId, status: 'pending' }, + orderBy: { requestedAt: 'asc' }, + }) + return rows.map(mapInterrupt) + }, + async listByRun(runId) { + const rows = await interrupt.findMany({ + where: { runId }, + orderBy: { requestedAt: 'asc' }, + }) + return rows.map(mapInterrupt) + }, + async listPendingByRun(runId) { + const rows = await interrupt.findMany({ + where: { runId, status: 'pending' }, + orderBy: { requestedAt: 'asc' }, + }) + return rows.map(mapInterrupt) + }, + } +} + +function assertStorableMetadata(value: unknown): void { + if (value == null) { + throw new TypeError( + `TanStack AI metadata values must be defined, non-null JSON; received ${ + value === undefined ? '`undefined`' : '`null`' + }. Use \`delete(scope, key)\` to clear a value.`, + ) + } +} + +export function createMetadataStore({ + metadata, +}: TanstackAiDelegates): MetadataStore { + return { + async get(scope, key) { + const row = await metadata.findUnique({ + where: { scope_key: { scope, key } }, + }) + return row ? (JSON.parse(row.valueJson) as unknown) : null + }, + async set(scope, key, value) { + // SQL backends store JSON text in a NOT NULL column and cannot persist a + // nullish value: `JSON.stringify(undefined)` is `undefined` (no string), + // and the sibling Drizzle backend binds a JS `null` as SQL NULL — both + // violate NOT NULL. Reject nullish with a clear error (consistent across + // the SQL backends) instead of a cryptic driver failure. Unlike the + // in-memory reference, which round-trips nullish; use `delete` to clear. + assertStorableMetadata(value) + const valueJson = JSON.stringify(value) + await metadata.upsert({ + where: { scope_key: { scope, key } }, + create: { scope, key, valueJson }, + update: { valueJson }, + }) + }, + async delete(scope, key) { + await metadata.deleteMany({ where: { scope, key } }) + }, + } +} diff --git a/packages/ai-persistence-prisma/tests/api-types.test-d.ts b/packages/ai-persistence-prisma/tests/api-types.test-d.ts new file mode 100644 index 000000000..67e4fc687 --- /dev/null +++ b/packages/ai-persistence-prisma/tests/api-types.test-d.ts @@ -0,0 +1,38 @@ +import { expectTypeOf } from 'vitest' +import { PrismaClient } from '@prisma/client' +import type { + InterruptStore, + MessageStore, + MetadataStore, + RunStore, +} from '@tanstack/ai-persistence' +import { prismaPersistence } from '../src/index' +import type { + InterruptDelegate, + MessageDelegate, + MetadataDelegate, + RunDelegate, +} from '../src/model-contract' + +declare const prisma: PrismaClient +const persistence = prismaPersistence(prisma) + +// The generated client's delegates must satisfy the structural delegate +// contract the stores are written against — this is what makes renamed +// delegates from a user-generated client interchangeable with canonical ones. +expectTypeOf(prisma.message).toExtend() +expectTypeOf(prisma.run).toExtend() +expectTypeOf(prisma.interrupt).toExtend() +expectTypeOf(prisma.metadata).toExtend() + +const mapped = prismaPersistence(prisma, { + models: { messages: 'chatMessage' }, +}) +expectTypeOf(mapped.stores).toEqualTypeOf() + +expectTypeOf(persistence.stores.messages).toEqualTypeOf() +expectTypeOf(persistence.stores.runs).toEqualTypeOf() +expectTypeOf(persistence.stores.interrupts).toEqualTypeOf() +expectTypeOf(persistence.stores.metadata).toEqualTypeOf() +// No `locks` store: this backend has no distributed lock (see prismaPersistence). +expectTypeOf(persistence.stores).not.toHaveProperty('locks') diff --git a/packages/ai-persistence-prisma/tests/model-mapping.test.ts b/packages/ai-persistence-prisma/tests/model-mapping.test.ts new file mode 100644 index 000000000..573243251 --- /dev/null +++ b/packages/ai-persistence-prisma/tests/model-mapping.test.ts @@ -0,0 +1,137 @@ +import { rm } from 'node:fs/promises' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { afterAll, describe, expect, it } from 'vitest' +import { PrismaClient } from '@prisma/client' +import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' +import { PrismaModelError, prismaPersistence } from '../src/index' + +const clients: Array = [] +const temporaryDirectories: Array = [] + +const sqliteTestSchema = ` + CREATE TABLE messages ( + thread_id TEXT NOT NULL PRIMARY KEY, + messages_json TEXT NOT NULL + ); + CREATE TABLE runs ( + run_id TEXT NOT NULL PRIMARY KEY, + thread_id TEXT NOT NULL, + status TEXT NOT NULL, + started_at BIGINT NOT NULL, + finished_at BIGINT, + error TEXT, + usage_json TEXT + ); + CREATE TABLE interrupts ( + interrupt_id TEXT NOT NULL PRIMARY KEY, + run_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + status TEXT NOT NULL, + requested_at BIGINT NOT NULL, + resolved_at BIGINT, + payload_json TEXT NOT NULL, + response_json TEXT + ); + CREATE TABLE metadata ( + scope TEXT NOT NULL, + key TEXT NOT NULL, + value_json TEXT NOT NULL, + PRIMARY KEY (scope, key) + ); +` + +async function makeTestClient(): Promise { + const dir = mkdtempSync(join(tmpdir(), 'tanstack-ai-prisma-mapping-')) + temporaryDirectories.push(dir) + const dbPath = join(dir, 'state.db').replace(/\\/g, '/') + const database = new DatabaseSync(dbPath) + try { + database.exec(sqliteTestSchema) + } finally { + database.close() + } + const prisma = new PrismaClient({ + datasources: { db: { url: `file:${dbPath}` } }, + }) + clients.push(prisma) + return prisma +} + +/** + * A client whose TanStack AI model delegates live under renamed accessors, as + * generated from a fragment whose models were renamed to avoid collisions + * (e.g. `model ChatMessage`). Aliasing the canonical delegates is faithful: + * a delegate is bound to its model, not to the property it hangs off. + */ +async function makeRenamedClient(): Promise { + const prisma = await makeTestClient() + const renamed = { + chatMessage: prisma.message, + chatRun: prisma.run, + chatInterrupt: prisma.interrupt, + chatMetadata: prisma.metadata, + } + return renamed as unknown as PrismaClient +} + +const renamedModels = { + messages: 'chatMessage', + runs: 'chatRun', + interrupts: 'chatInterrupt', + metadata: 'chatMetadata', +} + +afterAll(async () => { + await Promise.all(clients.map((client) => client.$disconnect())) + await Promise.all( + temporaryDirectories.map((directory) => + rm(directory, { recursive: true, force: true }), + ), + ) +}) + +// The full store contract must hold when every model is reached through a +// renamed delegate. +runPersistenceConformance( + 'prisma (renamed models)', + async () => + prismaPersistence(await makeRenamedClient(), { models: renamedModels }), + // This backend has no distributed lock primitive. + { skip: ['locks'] }, +) + +describe('prismaPersistence model mapping', () => { + it('rejects a mapping that points at missing delegates', async () => { + const prisma = await makeTestClient() + expect(() => + prismaPersistence(prisma, { models: { messages: 'chatMessage' } }), + ).toThrow(PrismaModelError) + expect(() => + prismaPersistence(prisma, { models: { messages: 'chatMessage' } }), + ).toThrow(/`messages` maps to `client\.chatMessage`/) + }) + + it('lists every unresolved model in one error', async () => { + const prisma = await makeRenamedClient() + // Default names resolve nothing on a fully renamed client. + expect(() => prismaPersistence(prisma)).toThrow( + /messages[\s\S]*runs[\s\S]*interrupts[\s\S]*metadata/, + ) + }) + + it('supports partial maps over an otherwise canonical client', async () => { + const prisma = await makeTestClient() + const persistence = prismaPersistence(prisma, { + models: { messages: 'message' }, + }) + await persistence.stores.messages.saveThread('thread-1', [ + { role: 'user', content: 'hello' }, + ]) + expect( + await persistence.stores.messages.loadThread('thread-1'), + ).toHaveLength(1) + }) +}) diff --git a/packages/ai-persistence-prisma/tests/models-cli.test.ts b/packages/ai-persistence-prisma/tests/models-cli.test.ts new file mode 100644 index 000000000..36a997568 --- /dev/null +++ b/packages/ai-persistence-prisma/tests/models-cli.test.ts @@ -0,0 +1,59 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { prismaModels, prismaModelsFilename } from '../src/index' +import { runPrismaModelsCli } from '../src/models-cli' + +const temporaryDirectories: Array = [] + +async function createTemporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'tanstack-ai-prisma-cli-')) + temporaryDirectories.push(directory) + return directory +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ) +}) + +describe('Prisma models CLI', () => { + it('prints the models fragment to stdout', async () => { + let output = '' + await runPrismaModelsCli(['--stdout'], { + writeStdout(value) { + output += value + }, + }) + expect(output).toBe(`${prismaModels.trimEnd()}\n`) + }) + + it('copies the fragment without overwriting divergent files by default', async () => { + const directory = await createTemporaryDirectory() + await runPrismaModelsCli(['--out', directory]) + + const destination = join(directory, prismaModelsFilename) + expect(await readFile(destination, 'utf8')).toBe(prismaModels) + + await writeFile(destination, 'user-owned contents', 'utf8') + await expect(runPrismaModelsCli(['--out', directory])).rejects.toThrow( + /refusing to overwrite/i, + ) + expect(await readFile(destination, 'utf8')).toBe('user-owned contents') + + await runPrismaModelsCli(['--out', directory, '--force']) + expect(await readFile(destination, 'utf8')).toBe(prismaModels) + }) + + it('fails on ambiguous or incomplete output options', async () => { + const directory = await createTemporaryDirectory() + await expect(runPrismaModelsCli([])).rejects.toThrow(/--out.*--stdout/i) + await expect( + runPrismaModelsCli(['--stdout', '--out', directory]), + ).rejects.toThrow(/exactly one/i) + }) +}) diff --git a/packages/ai-persistence-prisma/tests/models.test.ts b/packages/ai-persistence-prisma/tests/models.test.ts new file mode 100644 index 000000000..5711faaeb --- /dev/null +++ b/packages/ai-persistence-prisma/tests/models.test.ts @@ -0,0 +1,66 @@ +import { execFileSync } from 'node:child_process' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { prismaModels, prismaModelsFilename } from '../src/index' + +const temporaryDirectories: Array = [] +const prismaCli = fileURLToPath( + new URL('../node_modules/prisma/build/index.js', import.meta.url), +) + +async function createTemporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'tanstack-ai-prisma-models-')) + temporaryDirectories.push(directory) + return directory +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ) +}) + +describe('Prisma models asset', () => { + it('is a models-only fragment with no provider or client generator', () => { + expect(prismaModelsFilename).toBe('tanstack-ai.prisma') + expect(prismaModels).toContain('model Message') + expect(prismaModels).toContain('model Metadata') + expect(prismaModels).not.toMatch(/\bgenerator\s+\w+\s*{/) + expect(prismaModels).not.toMatch(/\bdatasource\s+\w+\s*{/) + }) + + it('keeps the shipped models file equal to the embedded asset', async () => { + const shipped = await readFile( + fileURLToPath(new URL('../prisma/tanstack-ai.prisma', import.meta.url)), + 'utf8', + ) + expect(shipped).toBe(prismaModels) + }) + + it.each([ + ['sqlite', 'file:./state.db'], + ['postgresql', 'postgresql://user:password@localhost:5432/database'], + ['mysql', 'mysql://user:password@localhost:3306/database'], + ])('validates when composed into a %s schema', async (provider, url) => { + const directory = await createTemporaryDirectory() + const schemaPath = join(directory, 'schema.prisma') + await writeFile( + schemaPath, + `datasource db {\n provider = "${provider}"\n url = "${url}"\n}\n\n${prismaModels}`, + 'utf8', + ) + + expect(() => + execFileSync( + process.execPath, + [prismaCli, 'validate', '--schema', schemaPath], + { encoding: 'utf8', stdio: 'pipe' }, + ), + ).not.toThrow() + }) +}) diff --git a/packages/ai-persistence-prisma/tests/package-contract.test.ts b/packages/ai-persistence-prisma/tests/package-contract.test.ts new file mode 100644 index 000000000..a823747df --- /dev/null +++ b/packages/ai-persistence-prisma/tests/package-contract.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import packageJson from '../package.json' + +describe('Prisma package contract', () => { + it('publishes the adapter, models CLI, and provider-neutral models asset', () => { + expect(packageJson.exports).toEqual({ + '.': { + types: './dist/esm/index.d.ts', + import: './dist/esm/index.js', + }, + }) + expect(packageJson.bin).toEqual({ + 'tanstack-ai-prisma-models': './bin/tanstack-ai-prisma-models.mjs', + }) + expect(packageJson.files).toEqual( + expect.arrayContaining([ + 'bin', + 'dist', + 'src', + 'prisma/tanstack-ai.prisma', + ]), + ) + }) + + it('requires a Prisma client with generally available multi-file schemas', () => { + expect(packageJson.peerDependencies['@prisma/client']).toBe('>=6.7.0') + }) +}) diff --git a/packages/ai-persistence-prisma/tests/prisma.conformance.test.ts b/packages/ai-persistence-prisma/tests/prisma.conformance.test.ts new file mode 100644 index 000000000..69c029199 --- /dev/null +++ b/packages/ai-persistence-prisma/tests/prisma.conformance.test.ts @@ -0,0 +1,82 @@ +import { rm } from 'node:fs/promises' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { afterAll } from 'vitest' +import { PrismaClient } from '@prisma/client' +import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' +import { prismaPersistence } from '../src/index' + +const clients: Array = [] +const temporaryDirectories: Array = [] + +const sqliteTestSchema = ` + CREATE TABLE messages ( + thread_id TEXT NOT NULL PRIMARY KEY, + messages_json TEXT NOT NULL + ); + CREATE TABLE runs ( + run_id TEXT NOT NULL PRIMARY KEY, + thread_id TEXT NOT NULL, + status TEXT NOT NULL, + started_at BIGINT NOT NULL, + finished_at BIGINT, + error TEXT, + usage_json TEXT + ); + CREATE TABLE interrupts ( + interrupt_id TEXT NOT NULL PRIMARY KEY, + run_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + status TEXT NOT NULL, + requested_at BIGINT NOT NULL, + resolved_at BIGINT, + payload_json TEXT NOT NULL, + response_json TEXT + ); + CREATE TABLE metadata ( + scope TEXT NOT NULL, + key TEXT NOT NULL, + value_json TEXT NOT NULL, + PRIMARY KEY (scope, key) + ); +` + +function initializeSqliteTestDatabase(path: string): void { + const database = new DatabaseSync(path) + try { + database.exec(sqliteTestSchema) + } finally { + database.close() + } +} + +/** Create a PrismaClient over a fresh initialized temporary SQLite database. */ +async function makeTestClient(): Promise { + const dir = mkdtempSync(join(tmpdir(), 'tanstack-ai-prisma-')) + temporaryDirectories.push(dir) + const dbPath = join(dir, 'state.db').replace(/\\/g, '/') + initializeSqliteTestDatabase(dbPath) + const prisma = new PrismaClient({ + datasources: { db: { url: `file:${dbPath}` } }, + }) + clients.push(prisma) + return prisma +} + +afterAll(async () => { + await Promise.all(clients.map((client) => client.$disconnect())) + await Promise.all( + temporaryDirectories.map((directory) => + rm(directory, { recursive: true, force: true }), + ), + ) +}) + +runPersistenceConformance( + 'prisma', + async () => prismaPersistence(await makeTestClient()), + // This backend has no distributed lock primitive. + { skip: ['locks'] }, +) diff --git a/packages/ai-persistence-prisma/tests/store-behavior.test.ts b/packages/ai-persistence-prisma/tests/store-behavior.test.ts new file mode 100644 index 000000000..f1b862b74 --- /dev/null +++ b/packages/ai-persistence-prisma/tests/store-behavior.test.ts @@ -0,0 +1,72 @@ +import { rm } from 'node:fs/promises' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { afterAll, describe, expect, it } from 'vitest' +import { PrismaClient } from '@prisma/client' +import { prismaPersistence } from '../src/index' + +const clients: Array = [] +const directories: Array = [] + +// Only the tables these tests query need to exist; the generated client carries +// all delegates regardless of which tables the database has. +const schema = ` + CREATE TABLE metadata ( + scope TEXT NOT NULL, + key TEXT NOT NULL, + value_json TEXT NOT NULL, + PRIMARY KEY (scope, key) + ); +` + +function makePersistence(): ReturnType { + const dir = mkdtempSync(join(tmpdir(), 'tanstack-ai-prisma-behavior-')) + directories.push(dir) + const dbPath = join(dir, 'state.db').replace(/\\/g, '/') + const database = new DatabaseSync(dbPath) + try { + database.exec(schema) + } finally { + database.close() + } + const prisma = new PrismaClient({ + datasources: { db: { url: `file:${dbPath}` } }, + }) + clients.push(prisma) + return prismaPersistence(prisma) +} + +afterAll(async () => { + await Promise.all(clients.map((client) => client.$disconnect())) + await Promise.all( + directories.map((directory) => + rm(directory, { recursive: true, force: true }), + ), + ) +}) + +describe('prisma metadata nullish values', () => { + it('rejects nullish values with a clear error instead of a NOT NULL crash', async () => { + const { stores } = makePersistence() + + // A NOT NULL column cannot store nullish; surface a clear, actionable error + // (consistent with the Drizzle backend) rather than a driver crash. + await expect(stores.metadata.set('scope', 'u', undefined)).rejects.toThrow( + /metadata values must be defined/, + ) + await expect(stores.metadata.set('scope', 'n', null)).rejects.toThrow( + /metadata values must be defined/, + ) + expect(await stores.metadata.get('scope', 'u')).toBeNull() + + // Falsy-but-defined values are stored and round-trip. + await stores.metadata.set('scope', 'zero', 0) + expect(await stores.metadata.get('scope', 'zero')).toBe(0) + await stores.metadata.set('scope', 'empty', '') + expect(await stores.metadata.get('scope', 'empty')).toBe('') + await stores.metadata.set('scope', 'obj', { a: 1 }) + expect(await stores.metadata.get('scope', 'obj')).toEqual({ a: 1 }) + }) +}) diff --git a/packages/ai-persistence-prisma/tsconfig.json b/packages/ai-persistence-prisma/tsconfig.json new file mode 100644 index 000000000..c38689f4e --- /dev/null +++ b/packages/ai-persistence-prisma/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ai-persistence-prisma/vite.config.ts b/packages/ai-persistence-prisma/vite.config.ts new file mode 100644 index 000000000..f56314fed --- /dev/null +++ b/packages/ai-persistence-prisma/vite.config.ts @@ -0,0 +1,37 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + '**/types.ts', + ], + include: ['src/**/*.ts'], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts', './src/cli.ts'], + srcDir: './src', + cjs: false, + }), +) diff --git a/packages/ai-persistence/package.json b/packages/ai-persistence/package.json new file mode 100644 index 000000000..05f20cd0f --- /dev/null +++ b/packages/ai-persistence/package.json @@ -0,0 +1,63 @@ +{ + "name": "@tanstack/ai-persistence", + "version": "0.0.0", + "description": "Composable state persistence for TanStack AI messages, runs, interrupts, metadata, and locks.", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-persistence" + }, + "keywords": [ + "ai", + "ai-sdk", + "typescript", + "tanstack", + "persistence", + "durable", + "resume", + "chat-history" + ], + "type": "module", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + }, + "./testkit": { + "types": "./dist/esm/testkit/conformance.d.ts", + "import": "./dist/esm/testkit/conformance.js" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "vite build", + "clean": "premove ./build ./dist", + "lint:fix": "oxlint src --type-aware --fix", + "test:build": "publint --strict", + "test:lib": "vitest", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc", + "test:oxlint": "oxlint src --type-aware" + }, + "peerDependencies": { + "@tanstack/ai": "workspace:^", + "vitest": "^4.1.10" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + }, + "devDependencies": { + "@tanstack/ai": "workspace:*", + "@vitest/coverage-v8": "4.0.14", + "vitest": "^4.1.10" + } +} diff --git a/packages/ai-persistence/src/capabilities.ts b/packages/ai-persistence/src/capabilities.ts new file mode 100644 index 000000000..9efd6e266 --- /dev/null +++ b/packages/ai-persistence/src/capabilities.ts @@ -0,0 +1,24 @@ +/** + * Persistence capability tokens. + * + * `withChatPersistence` PROVIDES these so later middleware (and harness adapters) + * can read durable state. `LocksCapability` is defined in `./locks` (see the note + * there): sandbox persistence, the only cross-package consumer, is deferred, so + * the token lives local to this package for now. + */ +import { createCapability } from '@tanstack/ai' +import type { AIPersistence, InterruptStore } from './types' + +export const PersistenceCapability = + createCapability()('persistence') + +export const InterruptsCapability = createCapability()( + 'persistence.interrupts', +) + +export const [getPersistence, providePersistence] = PersistenceCapability +export const [getInterrupts, provideInterrupts] = InterruptsCapability + +// Locks token lives in this package (see ./locks); re-export so consumers import +// everything persistence-related from here. +export { LocksCapability, getLocks, provideLocks } from './locks' diff --git a/packages/ai-persistence/src/index.ts b/packages/ai-persistence/src/index.ts new file mode 100644 index 000000000..969c7206e --- /dev/null +++ b/packages/ai-persistence/src/index.ts @@ -0,0 +1,43 @@ +// Store contracts + aggregate +export { composePersistence, defineAIPersistence } from './types' +export type { + MessageStore, + RunStatus, + RunRecord, + RunStore, + InterruptRecord, + InterruptStatus, + InterruptStore, + MetadataStore, + AIPersistence, + AIPersistenceStores, + AIPersistenceOverrides, + ComposedAIPersistenceStores, +} from './types' + +// Middleware +export { withChatPersistence, withGenerationPersistence } from './middleware' + +// Reference in-memory implementation +export { memoryPersistence } from './memory' + +// Interrupt controller +export { createInterruptController } from './interrupts' +export type { InterruptController } from './interrupts' + +// Capabilities (incl. the Locks token, owned by this package) +export { + PersistenceCapability, + InterruptsCapability, + getPersistence, + providePersistence, + getInterrupts, + provideInterrupts, + LocksCapability, + getLocks, + provideLocks, +} from './capabilities' + +// Lock primitive (owned here; sandbox persistence will bridge the token later) +export { InMemoryLockStore } from './locks' +export type { LockStore } from './locks' diff --git a/packages/ai-persistence/src/interrupts.ts b/packages/ai-persistence/src/interrupts.ts new file mode 100644 index 000000000..f8cc1d329 --- /dev/null +++ b/packages/ai-persistence/src/interrupts.ts @@ -0,0 +1,24 @@ +import type { InterruptRecord, InterruptStore } from './types' + +export interface InterruptController { + resolve: (interruptId: string, response?: unknown) => Promise + cancel: (interruptId: string) => Promise + request: ( + record: Omit, + ) => Promise + listPending: (threadId: string) => Promise> + listPendingByRun: (runId: string) => Promise> +} + +export function createInterruptController(opts: { + store: InterruptStore +}): InterruptController { + const { store } = opts + return { + resolve: (interruptId, response) => store.resolve(interruptId, response), + cancel: (interruptId) => store.cancel(interruptId), + request: (record) => store.create(record), + listPending: (threadId) => store.listPending(threadId), + listPendingByRun: (runId) => store.listPendingByRun(runId), + } +} diff --git a/packages/ai-persistence/src/locks.ts b/packages/ai-persistence/src/locks.ts new file mode 100644 index 000000000..0693a3782 --- /dev/null +++ b/packages/ai-persistence/src/locks.ts @@ -0,0 +1,60 @@ +/** + * Distributed-mutex primitive for the persistence layer. + * + * ponytail: the `'locks'` capability token is defined LOCALLY here. Capability + * identity is by object reference (see `createCapability`), not by name, so this + * token does NOT interoperate with the identically-named `LocksCapability` that + * `@tanstack/ai-sandbox` owns. That is fine for this batch: the only consumer of + * a persistence-provided lock store is `withSandbox`, and sandbox persistence is + * deferred. When it lands, share ONE handle between provider and consumer (the + * neutral home is core `@tanstack/ai`); until then this token has no cross-package + * consumer and just keeps the persistence API self-contained. + */ +import { createCapability } from '@tanstack/ai' + +/** + * Mutual exclusion around a critical section keyed by `key`. A cloudflare + * Durable Object store is the only backend here that is safe across instances; + * the in-memory default is correct within a single process only. Lease-backed + * implementations abort `signal` as soon as ownership can no longer be + * guaranteed; the callback must stop externally visible mutations when it aborts. + */ +export interface LockStore { + withLock: ( + key: string, + fn: (signal: AbortSignal) => Promise, + ) => Promise +} + +/** The lock capability. Provided by `withChatPersistence` when a `locks` store is present. */ +export const LocksCapability = createCapability()('locks') + +/** Destructured accessors: `getLocks(ctx)` / `provideLocks(ctx, store)`. */ +export const [getLocks, provideLocks] = LocksCapability + +/** + * In-memory {@link LockStore} — a per-key promise chain. Correct within a single + * process; multi-instance correctness needs a distributed lock from the backend. + */ +export class InMemoryLockStore implements LockStore { + private readonly chains = new Map>() + + withLock( + key: string, + fn: (signal: AbortSignal) => Promise, + ): Promise { + const prior = this.chains.get(key) ?? Promise.resolve() + const runCriticalSection = () => fn(new AbortController().signal) + // Chain after the prior holder regardless of how it settled. + const run = prior.then(runCriticalSection, runCriticalSection) + // Keep the chain alive but swallow rejections so one failure doesn't poison the lock. + this.chains.set( + key, + run.then( + () => undefined, + () => undefined, + ), + ) + return run + } +} diff --git a/packages/ai-persistence/src/memory.ts b/packages/ai-persistence/src/memory.ts new file mode 100644 index 000000000..da9d0d80c --- /dev/null +++ b/packages/ai-persistence/src/memory.ts @@ -0,0 +1,165 @@ +import { InMemoryLockStore } from './locks' +import { defineAIPersistence } from './types' +import type { LockStore } from './locks' +import type { ModelMessage } from '@tanstack/ai' +import type { + InterruptRecord, + InterruptStore, + MessageStore, + MetadataStore, + RunRecord, + RunStore, +} from './types' + +class MemoryMessageStore implements MessageStore { + private readonly threads = new Map>() + loadThread(threadId: string): Promise> { + return Promise.resolve(this.threads.get(threadId)?.slice() ?? []) + } + saveThread(threadId: string, messages: Array): Promise { + this.threads.set(threadId, messages.slice()) + return Promise.resolve() + } +} + +class MemoryRunStore implements RunStore { + private readonly runs = new Map() + createOrResume(input: { + runId: string + threadId: string + status?: RunRecord['status'] + startedAt: number + }): Promise { + const existing = this.runs.get(input.runId) + if (existing) return Promise.resolve(existing) + const record: RunRecord = { + runId: input.runId, + threadId: input.threadId, + status: input.status ?? 'running', + startedAt: input.startedAt, + } + this.runs.set(record.runId, record) + return Promise.resolve(record) + } + update( + runId: string, + patch: Partial< + Pick + >, + ): Promise { + const existing = this.runs.get(runId) + if (existing) this.runs.set(runId, { ...existing, ...patch }) + return Promise.resolve() + } + get(runId: string): Promise { + return Promise.resolve(this.runs.get(runId) ?? null) + } +} + +class MemoryInterruptStore implements InterruptStore { + private readonly interrupts = new Map() + create( + record: Omit, + ): Promise { + // Insert-if-absent (canonical semantics, matching the SQL backends' + // ON CONFLICT DO NOTHING): a duplicate id must never clobber an existing — + // possibly already resolved — interrupt back to pending. + if (!this.interrupts.has(record.interruptId)) { + this.interrupts.set(record.interruptId, { ...record, status: 'pending' }) + } + return Promise.resolve() + } + resolve(interruptId: string, response?: unknown): Promise { + const existing = this.interrupts.get(interruptId) + if (existing) { + this.interrupts.set(interruptId, { + ...existing, + status: 'resolved', + resolvedAt: Date.now(), + response, + }) + } + return Promise.resolve() + } + cancel(interruptId: string): Promise { + const existing = this.interrupts.get(interruptId) + if (existing) { + this.interrupts.set(interruptId, { + ...existing, + status: 'cancelled', + resolvedAt: Date.now(), + }) + } + return Promise.resolve() + } + get(interruptId: string): Promise { + return Promise.resolve(this.interrupts.get(interruptId) ?? null) + } + list(threadId: string): Promise> { + return Promise.resolve( + [...this.interrupts.values()].filter( + (interrupt) => interrupt.threadId === threadId, + ), + ) + } + listPending(threadId: string): Promise> { + return Promise.resolve( + [...this.interrupts.values()].filter( + (interrupt) => + interrupt.threadId === threadId && interrupt.status === 'pending', + ), + ) + } + listByRun(runId: string): Promise> { + return Promise.resolve( + [...this.interrupts.values()].filter( + (interrupt) => interrupt.runId === runId, + ), + ) + } + listPendingByRun(runId: string): Promise> { + return Promise.resolve( + [...this.interrupts.values()].filter( + (interrupt) => + interrupt.runId === runId && interrupt.status === 'pending', + ), + ) + } +} + +class MemoryMetadataStore implements MetadataStore { + private readonly values = new Map() + get(scope: string, key: string): Promise { + const storageKey = `${scope}:${key}` + return Promise.resolve( + this.values.has(storageKey) ? this.values.get(storageKey) : null, + ) + } + set(scope: string, key: string, value: unknown): Promise { + this.values.set(`${scope}:${key}`, value) + return Promise.resolve() + } + delete(scope: string, key: string): Promise { + this.values.delete(`${scope}:${key}`) + return Promise.resolve() + } +} + +interface MemoryPersistenceStores { + messages: MessageStore + runs: RunStore + interrupts: InterruptStore + metadata: MetadataStore + locks: LockStore +} + +export function memoryPersistence() { + const stores: MemoryPersistenceStores = { + messages: new MemoryMessageStore(), + runs: new MemoryRunStore(), + interrupts: new MemoryInterruptStore(), + metadata: new MemoryMetadataStore(), + locks: new InMemoryLockStore(), + } + return defineAIPersistence({ stores }) +} diff --git a/packages/ai-persistence/src/middleware.ts b/packages/ai-persistence/src/middleware.ts new file mode 100644 index 000000000..fd170b5be --- /dev/null +++ b/packages/ai-persistence/src/middleware.ts @@ -0,0 +1,434 @@ +import { defineChatMiddleware } from '@tanstack/ai' +import { + InterruptsCapability, + LocksCapability, + PersistenceCapability, + provideInterrupts, + provideLocks, + providePersistence, +} from './capabilities' +import { + validateChatPersistenceStores, + validatePersistenceStoreKeys, +} from './types' +import type { + AbortInfo, + ChatMiddleware, + ChatMiddlewareConfig, + ChatMiddlewareContext, + ErrorInfo, + FinishInfo, + GenerationAbortInfo, + GenerationErrorInfo, + GenerationFinishInfo, + GenerationMiddleware, + GenerationMiddlewareContext, + RunAgentResumeItem, + StreamChunk, + TokenUsage, +} from '@tanstack/ai' +import type { + AIPersistence, + AIPersistenceStores, + InterruptRecord, + RunStore, +} from './types' + +interface RunStateEntry { + merged: boolean + interrupted: boolean + /** + * Resumes accepted in `onConfig` but not yet committed to the interrupt + * store. They are applied (resolve/cancel) only once the run reaches a + * successful boundary — see {@link commitPendingResumes}. Left uncommitted + * (still pending in the store) if the run fails or aborts first. + */ + pendingResumes?: { + pending: Array + resumeByInterruptId: Map + } +} + +const runState = new WeakMap() + +const validResumeStatuses = new Set(['resolved', 'cancelled']) + +function validatePendingResumes( + pending: Array, + resume: Array | undefined, +): Map { + const pendingInterruptIds = new Set( + pending.map((interrupt) => interrupt.interruptId), + ) + const resumeByInterruptId = new Map( + (resume ?? []).map((entry) => [entry.interruptId, entry]), + ) + if (pending.length === 0) { + const staleEntry = resume?.[0] + if (staleEntry) { + throw new Error( + `Resume entry references non-pending interrupt ${staleEntry.interruptId}.`, + ) + } + return resumeByInterruptId + } + if (!resume || resume.length === 0) { + throw new Error( + `Thread has pending interrupts; resume is required before accepting new input.`, + ) + } + + for (const interrupt of pending) { + const entry = resumeByInterruptId.get(interrupt.interruptId) + if (!entry) { + throw new Error( + `Missing resume entry for pending interrupt ${interrupt.interruptId}.`, + ) + } + if (!validResumeStatuses.has(entry.status)) { + throw new Error( + `Invalid resume status for pending interrupt ${interrupt.interruptId}: ${entry.status}.`, + ) + } + } + for (const entry of resume) { + if (!pendingInterruptIds.has(entry.interruptId)) { + throw new Error( + `Resume entry references non-pending interrupt ${entry.interruptId}.`, + ) + } + } + return resumeByInterruptId +} + +async function applyPendingResumes( + pending: Array, + resumeByInterruptId: Map, + interrupts: NonNullable, +): Promise { + for (const interrupt of pending) { + const entry = resumeByInterruptId.get(interrupt.interruptId) + if (!entry) continue + if (entry.status === 'resolved') { + await interrupts.resolve(interrupt.interruptId, entry.payload) + } else { + await interrupts.cancel(interrupt.interruptId) + } + } +} + +/** + * Commit the resumes stashed in `onConfig`, marking each resumed interrupt + * resolved/cancelled. Called only from success boundaries (`onFinish`, and the + * `onChunk` interrupt boundary) so a provider failure or abort between accepting + * the resume and reaching a boundary leaves the interrupts pending — the + * approval is not consumed and a retry with the same resume succeeds. Idempotent + * and a no-op when nothing is stashed. + */ +async function commitPendingResumes( + state: RunStateEntry | undefined, + interrupts: AIPersistence['stores']['interrupts'], +): Promise { + if (!state?.pendingResumes || !interrupts) return + const { pending, resumeByInterruptId } = state.pendingResumes + state.pendingResumes = undefined + await applyPendingResumes(pending, resumeByInterruptId, interrupts) +} + +function interruptPayload(interrupt: unknown): Record { + return interrupt && typeof interrupt === 'object' + ? { ...(interrupt as Record) } + : { value: interrupt } +} + +// --------------------------------------------------------------------------- +// Shared store / feature plan +// --------------------------------------------------------------------------- + +interface PersistencePlan { + wantsMessages: boolean + wantsInterrupts: boolean + wantsLocks: boolean + runs: AIPersistence['stores']['runs'] +} + +function resolvePersistencePlan(persistence: AIPersistence): PersistencePlan { + return { + wantsMessages: persistence.stores.messages !== undefined, + wantsInterrupts: persistence.stores.interrupts !== undefined, + wantsLocks: persistence.stores.locks !== undefined, + runs: persistence.stores.runs, + } +} + +type StoreIsDefinitelyPresent< + TStores extends AIPersistenceStores, + TKey extends keyof AIPersistenceStores, +> = TKey extends keyof TStores + ? object extends Pick + ? false + : [Exclude] extends [never] + ? false + : true + : false + +type StoreIsDefinitelyAbsent< + TStores extends AIPersistenceStores, + TKey extends keyof AIPersistenceStores, +> = TKey extends keyof TStores + ? [Exclude] extends [never] + ? true + : false + : true + +type InvalidChatPersistence = + StoreIsDefinitelyPresent extends true + ? StoreIsDefinitelyAbsent + : false + +type ValidChatPersistence = + InvalidChatPersistence extends true ? never : unknown + +async function createOrResumeRun( + runs: RunStore | undefined, + runId: string, + threadId: string, +): Promise { + await runs?.createOrResume({ + runId, + threadId, + startedAt: Date.now(), + }) +} + +async function completeRun( + runs: RunStore | undefined, + runId: string, + usage?: TokenUsage, +): Promise { + await runs?.update(runId, { + status: 'completed', + finishedAt: Date.now(), + ...(usage ? { usage } : {}), + }) +} + +async function failRun( + runs: RunStore | undefined, + runId: string, + error: unknown, +): Promise { + await runs?.update(runId, { + status: 'failed', + finishedAt: Date.now(), + error: error instanceof Error ? error.message : String(error), + }) +} + +async function interruptRun( + runs: RunStore | undefined, + runId: string, +): Promise { + await runs?.update(runId, { + status: 'interrupted', + finishedAt: Date.now(), + }) +} + +// --------------------------------------------------------------------------- +// Chat middleware +// --------------------------------------------------------------------------- + +/** + * Chat-only persistence middleware. Provides durable **state** for `chat()`: + * thread messages, run records, interrupts, and locks. This middleware never + * mutates the chunk stream; delivery durability (replaying a + * disconnected/reloaded stream) is a separate transport-layer concern tracked + * in PR #955. + * + * ⚠️ AUTHORITATIVE-HISTORY CONTRACT: when a request carries a non-empty + * `messages` array it is treated as the FULL conversation history and, on + * finish, **overwrites** the entire stored thread. Post only the complete + * transcript, never a delta — sending just the newest message(s) will replace + * (and thereby destroy) the stored thread. To continue a stored thread without + * resending history, pass an empty `messages` array and the stored transcript + * is loaded and used. + */ +export function withChatPersistence( + persistence: AIPersistence & ValidChatPersistence, +): ChatMiddleware +export function withChatPersistence( + persistence: AIPersistence, +): ChatMiddleware { + validateChatPersistenceStores(persistence) + const plan = resolvePersistencePlan(persistence) + const { wantsMessages, wantsInterrupts, wantsLocks, runs } = plan + + const provides = [ + PersistenceCapability, + ...(wantsInterrupts ? [InterruptsCapability] : []), + ...(wantsLocks ? [LocksCapability] : []), + ] + + return defineChatMiddleware({ + name: 'chat-persistence', + provides, + setup(ctx: ChatMiddlewareContext) { + providePersistence(ctx, persistence) + + runState.set(ctx, { + merged: false, + interrupted: false, + }) + + if (wantsInterrupts && persistence.stores.interrupts) { + provideInterrupts(ctx, persistence.stores.interrupts) + } + if (wantsLocks && persistence.stores.locks) { + provideLocks(ctx, persistence.stores.locks) + } + }, + + async onConfig(ctx: ChatMiddlewareContext, config: ChatMiddlewareConfig) { + if (ctx.phase !== 'init') return + + 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. The chat engine reconstructs the actual resume tool + // state itself, from `config.resume` plus the interrupt bindings carried + // in the (server-loaded) message history — persistence only records the + // interrupts and gates new input, it no longer feeds resume maps. + const resumeByInterruptId = validatePendingResumes( + pending, + config.resume, + ) + // Defer marking these interrupts resolved/cancelled until the run + // succeeds (see commitPendingResumes). Committing here would consume the + // approval even if the run then failed, breaking a retry. + const state = runState.get(ctx) + if (state && pending.length > 0) { + state.pendingResumes = { pending, resumeByInterruptId } + } + } + + await createOrResumeRun(runs, ctx.runId, ctx.threadId) + + if (!wantsMessages || !persistence.stores.messages) return + const state = runState.get(ctx) + if (state?.merged) return + if (state) state.merged = true + + const stored = await persistence.stores.messages.loadThread(ctx.threadId) + const merged = config.messages.length > 0 ? config.messages : stored + return { messages: merged } + }, + + async onChunk(ctx: ChatMiddlewareContext, chunk: StreamChunk) { + // State-only: react to the interrupt boundary (create interrupt records, + // mark the run interrupted, snapshot thread messages). The chunk stream is + // never mutated — delivery durability is a transport-layer concern. + if ( + chunk.type !== 'RUN_FINISHED' || + chunk.outcome?.type !== 'interrupt' + ) { + return + } + const state = runState.get(ctx) + if (!state) return + + if (wantsInterrupts && persistence.stores.interrupts) { + // The run reached a new interrupt boundary, so the resumes it consumed + // are committed before the fresh interrupts are recorded. + await commitPendingResumes(state, persistence.stores.interrupts) + for (const interrupt of chunk.outcome.interrupts) { + await persistence.stores.interrupts.create({ + interruptId: interrupt.id, + runId: ctx.runId, + threadId: ctx.threadId, + requestedAt: Date.now(), + payload: interruptPayload(interrupt), + }) + } + } + await interruptRun(runs, ctx.runId) + if (wantsMessages && persistence.stores.messages) { + await persistence.stores.messages.saveThread(ctx.threadId, [ + ...ctx.messages, + ]) + } + state.interrupted = true + }, + + async onFinish(ctx: ChatMiddlewareContext, info: FinishInfo) { + const state = runState.get(ctx) + if (state?.interrupted) return + // The run completed successfully, so commit the resumes it consumed. + await commitPendingResumes(state, persistence.stores.interrupts) + await completeRun(runs, ctx.runId, info.usage) + if (wantsMessages && persistence.stores.messages) { + await persistence.stores.messages.saveThread(ctx.threadId, [ + ...ctx.messages, + ]) + } + }, + + async onError(ctx: ChatMiddlewareContext, info: ErrorInfo) { + await failRun(runs, ctx.runId, info.error) + }, + + async onAbort(ctx: ChatMiddlewareContext, _info: AbortInfo) { + await interruptRun(runs, ctx.runId) + }, + }) +} + +// --------------------------------------------------------------------------- +// Generation middleware +// --------------------------------------------------------------------------- + +/** + * Generation-only persistence middleware. Tracks run status (run records) for + * image, audio, TTS, video, and transcription activities. + */ +export function withGenerationPersistence( + persistence: AIPersistence, +): GenerationMiddleware +export function withGenerationPersistence( + persistence: AIPersistence, +): GenerationMiddleware { + validatePersistenceStoreKeys(persistence) + const { runs } = resolvePersistencePlan(persistence) + + return { + name: 'generation-persistence', + + async onStart(ctx: GenerationMiddlewareContext) { + await createOrResumeRun( + runs, + ctx.runId ?? ctx.requestId, + ctx.threadId ?? ctx.requestId, + ) + }, + + async onFinish( + ctx: GenerationMiddlewareContext, + info: GenerationFinishInfo, + ) { + await completeRun(runs, ctx.runId ?? ctx.requestId, info.usage) + }, + + async onError(ctx: GenerationMiddlewareContext, info: GenerationErrorInfo) { + await failRun(runs, ctx.runId ?? ctx.requestId, info.error) + }, + + async onAbort( + ctx: GenerationMiddlewareContext, + _info: GenerationAbortInfo, + ) { + await interruptRun(runs, ctx.runId ?? ctx.requestId) + }, + } +} diff --git a/packages/ai-persistence/src/testkit/conformance.ts b/packages/ai-persistence/src/testkit/conformance.ts new file mode 100644 index 000000000..fd24a7c58 --- /dev/null +++ b/packages/ai-persistence/src/testkit/conformance.ts @@ -0,0 +1,379 @@ +/** + * Shared conformance suite for the `AIPersistence` state contract. + * + * Every backend (memory, drizzle, prisma, cloudflare, …) runs this identical + * suite so that schema drift or an implementation gap fails immediately. It + * exercises every method of every store the persistence exposes and is the + * authoritative compatibility gate for the store interfaces in `../types.ts`. + * + * SKIPPING: a backend that deliberately omits a store (e.g. drizzle has no + * `locks`) must declare it in `options.skip`. A store that is absent AND not + * listed in `skip` fails the suite loudly — silent gaps are not allowed. + */ +import { beforeAll, describe, expect, it } from 'vitest' +import type { ModelMessage } from '@tanstack/ai' +import type { AIPersistence, AIPersistenceStores } from '../types' + +type MakePersistence = () => Promise | AIPersistence + +export interface PersistenceConformanceOptions { + /** + * Store keys this backend intentionally does not provide. Any store that is + * absent from the persistence and NOT listed here fails the suite, so a + * dropped/misconfigured store can never pass silently. + */ + skip?: Array +} + +/** + * Register a Vitest suite that validates `makePersistence()` against the full + * `AIPersistence` contract. + */ +export function runPersistenceConformance( + name: string, + makePersistence: MakePersistence, + options?: PersistenceConformanceOptions, +): void { + const skip = new Set(options?.skip ?? []) + + describe(`AIPersistence conformance: ${name}`, () => { + let persistence: AIPersistence + + beforeAll(async () => { + persistence = await makePersistence() + }) + + /** + * Return the store for `key`, or `null` when the backend intentionally + * skips it. Throws (failing the test) when a store is missing but was not + * declared in `options.skip`. + */ + function resolveStore( + key: TKey, + ): NonNullable | null { + const store = persistence.stores[key] + if (store) return store + if (skip.has(key)) return null + throw new Error( + `AIPersistence conformance: store '${key}' is missing. ` + + `Provide it, or pass { skip: ['${key}'] } if the omission is intentional.`, + ) + } + + describe('messages', () => { + it('round-trips a thread and returns [] for unknown threads', async () => { + const store = resolveStore('messages') + if (!store) return + + expect(await store.loadThread('thread-unknown')).toEqual([]) + + await store.saveThread('thread-msg', [ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: 'hello' }, + ]) + expect(await store.loadThread('thread-msg')).toEqual([ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: 'hello' }, + ]) + + // Overwrites, not appends. + await store.saveThread('thread-msg', [ + { role: 'user', content: 'redo' }, + ]) + expect(await store.loadThread('thread-msg')).toEqual([ + { role: 'user', content: 'redo' }, + ]) + }) + + it('round-trips rich message shapes with deep equality', async () => { + const store = resolveStore('messages') + if (!store) return + + const rich: Array = [ + { role: 'user', content: 'plain string' }, + { + // Tool-call message with JSON arguments. + role: 'assistant', + content: '', + toolCalls: [ + { + id: 'call-1', + type: 'function', + function: { + name: 'search', + arguments: '{"query":"weather in Paris"}', + }, + }, + ], + }, + { + // Tool result message. + role: 'tool', + content: '{"temperature":21,"unit":"C"}', + toolCallId: 'call-1', + }, + { + // Multi-part content: text + image reference. + role: 'user', + content: [ + { type: 'text', content: 'What is in this image?' }, + { + type: 'image', + source: { + type: 'url', + value: 'https://example.com/cat.png', + mimeType: 'image/png', + }, + }, + ], + }, + { + // Reasoning / thinking part. + role: 'assistant', + content: 'Here is my answer.', + thinking: [ + { + content: 'The user is asking about the image.', + signature: 'sig-1', + }, + ], + }, + ] + + await store.saveThread('thread-rich', rich) + expect(await store.loadThread('thread-rich')).toEqual(rich) + }) + }) + + describe('runs', () => { + it('creates, resumes idempotently, updates, and gets', async () => { + const store = resolveStore('runs') + if (!store) return + + expect(await store.get('run-missing')).toBeNull() + + const created = await store.createOrResume({ + runId: 'run-1', + threadId: 'thread-1', + startedAt: 1000, + }) + expect(created).toMatchObject({ + runId: 'run-1', + threadId: 'thread-1', + status: 'running', + startedAt: 1000, + }) + + // createOrResume is idempotent: returns the existing record unchanged. + const resumed = await store.createOrResume({ + runId: 'run-1', + threadId: 'thread-different', + startedAt: 9999, + }) + expect(resumed).toMatchObject({ + runId: 'run-1', + threadId: 'thread-1', + startedAt: 1000, + }) + + await store.update('run-1', { + status: 'completed', + finishedAt: 2000, + usage: { promptTokens: 3, completionTokens: 4, totalTokens: 7 }, + }) + const done = await store.get('run-1') + expect(done).toMatchObject({ + runId: 'run-1', + status: 'completed', + finishedAt: 2000, + usage: { promptTokens: 3, completionTokens: 4, totalTokens: 7 }, + }) + + await store.update('run-1', { status: 'failed', error: 'boom' }) + const failed = await store.get('run-1') + expect(failed?.status).toBe('failed') + expect(failed?.error).toBe('boom') + + // Updating a missing run is a no-op (does not throw, does not create). + await store.update('run-absent', { status: 'completed' }) + expect(await store.get('run-absent')).toBeNull() + }) + }) + + describe('interrupts', () => { + it('creates, resolves, cancels, and lists by thread and run', async () => { + const store = resolveStore('interrupts') + if (!store) return + + expect(await store.get('int-missing')).toBeNull() + + await store.create({ + interruptId: 'int-1', + runId: 'run-i', + threadId: 'thread-i', + requestedAt: 10, + payload: { tool: 'search', args: { q: 'x' } }, + }) + await store.create({ + interruptId: 'int-2', + runId: 'run-i', + threadId: 'thread-i', + requestedAt: 20, + payload: { tool: 'write' }, + }) + await store.create({ + interruptId: 'int-3', + runId: 'run-other', + threadId: 'thread-i', + requestedAt: 30, + payload: {}, + }) + + const one = await store.get('int-1') + expect(one).toMatchObject({ + interruptId: 'int-1', + runId: 'run-i', + threadId: 'thread-i', + status: 'pending', + requestedAt: 10, + payload: { tool: 'search', args: { q: 'x' } }, + }) + + expect( + (await store.list('thread-i')).map((r) => r.interruptId), + ).toEqual(['int-1', 'int-2', 'int-3']) + expect( + (await store.listByRun('run-i')).map((r) => r.interruptId), + ).toEqual(['int-1', 'int-2']) + expect( + (await store.listPending('thread-i')).map((r) => r.interruptId), + ).toEqual(['int-1', 'int-2', 'int-3']) + + await store.resolve('int-1', { ok: true }) + const resolved = await store.get('int-1') + expect(resolved?.status).toBe('resolved') + expect(resolved?.response).toEqual({ ok: true }) + expect(typeof resolved?.resolvedAt).toBe('number') + + await store.cancel('int-2') + const cancelled = await store.get('int-2') + expect(cancelled?.status).toBe('cancelled') + expect(typeof cancelled?.resolvedAt).toBe('number') + + expect( + (await store.listPending('thread-i')).map((r) => r.interruptId), + ).toEqual(['int-3']) + expect( + (await store.listPendingByRun('run-i')).map((r) => r.interruptId), + ).toEqual([]) + }) + + it('create is insert-if-absent: a duplicate id never clobbers a resolved interrupt', async () => { + const store = resolveStore('interrupts') + if (!store) return + + await store.create({ + interruptId: 'int-dup', + runId: 'run-dup', + threadId: 'thread-dup', + requestedAt: 100, + payload: { attempt: 1 }, + }) + await store.resolve('int-dup', { answer: 42 }) + + // A second create with the SAME id must be a no-op — not overwrite the + // now-resolved record back to pending with a fresh payload. + await store.create({ + interruptId: 'int-dup', + runId: 'run-dup', + threadId: 'thread-dup', + requestedAt: 200, + payload: { attempt: 2 }, + }) + + const after = await store.get('int-dup') + expect(after?.status).toBe('resolved') + expect(after?.response).toEqual({ answer: 42 }) + expect(after?.payload).toEqual({ attempt: 1 }) + expect(after?.requestedAt).toBe(100) + }) + }) + + describe('metadata', () => { + it('sets, gets, scopes, and deletes', async () => { + const store = resolveStore('metadata') + if (!store) return + + expect(await store.get('scope-a', 'k')).toBeNull() + + await store.set('scope-a', 'k', { n: 1 }) + await store.set('scope-b', 'k', { n: 2 }) + expect(await store.get('scope-a', 'k')).toEqual({ n: 1 }) + expect(await store.get('scope-b', 'k')).toEqual({ n: 2 }) + + await store.set('scope-a', 'k', { n: 3 }) + expect(await store.get('scope-a', 'k')).toEqual({ n: 3 }) + + await store.delete('scope-a', 'k') + expect(await store.get('scope-a', 'k')).toBeNull() + // Delete is scoped: scope-b untouched. + expect(await store.get('scope-b', 'k')).toEqual({ n: 2 }) + }) + }) + describe('locks', () => { + it('runs the critical section and returns its value', async () => { + const store = resolveStore('locks') + if (!store) return + + const order: Array = [] + const result = await store.withLock('lock-key', () => { + order.push('inside') + return Promise.resolve(42) + }) + expect(result).toBe(42) + expect(order).toEqual(['inside']) + }) + + it('serializes concurrent holders of the same key (mutual exclusion)', async () => { + const store = resolveStore('locks') + if (!store) return + + let active = 0 + let overlaps = 0 + const enterExit = async () => { + active += 1 + if (active > 1) overlaps += 1 + // Yield so a second critical section would interleave if the lock + // failed to exclude it. + await Promise.resolve() + await Promise.resolve() + active -= 1 + } + + await Promise.all([ + store.withLock('mx-key', enterExit), + store.withLock('mx-key', enterExit), + store.withLock('mx-key', enterExit), + ]) + + expect(overlaps).toBe(0) + }) + + it('releases the lock when the critical section throws', async () => { + const store = resolveStore('locks') + if (!store) return + + await expect( + store.withLock('throw-key', () => Promise.reject(new Error('boom'))), + ).rejects.toThrow('boom') + + // The lock must have been released despite the throw: a subsequent + // acquisition runs rather than deadlocking. + const result = await store.withLock('throw-key', () => + Promise.resolve('recovered'), + ) + expect(result).toBe('recovered') + }) + }) + }) +} diff --git a/packages/ai-persistence/src/types.ts b/packages/ai-persistence/src/types.ts new file mode 100644 index 000000000..6213c4e36 --- /dev/null +++ b/packages/ai-persistence/src/types.ts @@ -0,0 +1,386 @@ +import type { ModelMessage, TokenUsage } from '@tanstack/ai' +import type { LockStore } from './locks' + +// =========================================================================== +// Store contracts +// =========================================================================== +// +// EVOLUTION POLICY +// ---------------- +// These store interfaces are the compatibility surface between the core +// middleware and every backend (memory, drizzle, prisma, cloudflare, …). +// To avoid breaking existing adapters: +// +// - New store methods are added as OPTIONAL (`method?: (...) => ...`). The +// middleware feature-detects them (`store.method?.(...)`) and degrades +// gracefully when a backend has not implemented them yet. +// - Never tighten an existing method's required arguments or widen its +// required return shape in a breaking way. +// +// The shared conformance testkit (`./testkit/conformance.ts`) is the +// authoritative compatibility gate: every invariant documented on the methods +// below is asserted there, and every backend runs the identical suite. If an +// invariant is not encoded in the testkit, adapters cannot discover it — so +// promote new invariants into both the JSDoc here AND the testkit. +// +// TIMESTAMP CONVENTION +// -------------------- +// Store *records* (`RunRecord`, `InterruptRecord`) speak **epoch +// milliseconds** (`number`), the native unit for SQL/`BIGINT` columns and +// `Date.now()`. Wire/result references that leave the persistence layer speak +// **ISO-8601 strings**. The middleware performs the number→ISO conversion at +// the boundary; do not mix the two on a single field. + +/** + * Durable store for a thread's full message transcript. + * + * A "thread" is the unit of conversation history. `saveThread` always receives + * and persists the **complete, authoritative** message list — it is an + * overwrite, never an append. The middleware snapshots `ctx.messages` (the full + * running transcript) into it. + */ +export interface MessageStore { + /** + * Return the full stored transcript for `threadId`, in insertion order. + * + * INVARIANT: returns an empty array (never `null`/`undefined`) for a thread + * that was never saved. Callers treat `[]` as "no history". + */ + loadThread: (threadId: string) => Promise> + /** + * Overwrite the stored transcript for `threadId` with `messages`. + * + * INVARIANT: this is a full replace. `messages` is the complete authoritative + * history; the previous contents are discarded (not merged or appended). + */ + saveThread: (threadId: string, messages: Array) => Promise +} + +export type RunStatus = 'running' | 'completed' | 'failed' | 'interrupted' + +/** + * A single generation/chat run. + * + * @property startedAt - Epoch ms when the run was first created. + * @property finishedAt - Epoch ms when the run reached a terminal status. + */ +export interface RunRecord { + runId: string + threadId: string + status: RunStatus + startedAt: number + finishedAt?: number + error?: string + usage?: TokenUsage +} + +/** Durable store for run lifecycle records. */ +export interface RunStore { + /** + * Create a run record, or return the existing one if `runId` is already + * present (resume). + * + * INVARIANT (idempotency): if a record for `runId` already exists it is + * returned **unchanged** and the passed `threadId`/`startedAt`/`status` are + * ignored. This is what makes resuming a run safe — the second call for a + * `runId` must not mutate `startedAt`, `threadId`, or status. `status` + * defaults to `'running'` on first creation. + */ + createOrResume: ( + input: Pick & { + status?: RunStatus + }, + ) => Promise + /** + * Patch a run record's mutable fields. + * + * INVARIANT: updating a `runId` that does not exist is a **no-op** — it must + * not throw and must not create a record. + */ + update: ( + runId: string, + patch: Partial< + Pick + >, + ) => Promise + /** Return the run record for `runId`, or `null` if none exists. */ + get: (runId: string) => Promise +} + +/** Lifecycle status of a human-in-the-loop interrupt. */ +export type InterruptStatus = 'pending' | 'resolved' | 'cancelled' + +/** + * A human-in-the-loop interrupt (tool approval, client-tool input request, …). + * + * @property requestedAt - Epoch ms when the interrupt was created. + * @property resolvedAt - Epoch ms when the interrupt was resolved/cancelled; + * absent while pending. + */ +export interface InterruptRecord { + interruptId: string + runId: string + threadId: string + status: InterruptStatus + requestedAt: number + resolvedAt?: number + payload: Record + response?: unknown +} + +/** Durable store for human-in-the-loop interrupts. */ +export interface InterruptStore { + /** + * Persist a new interrupt in the `'pending'` state. + * + * The record is accepted without `status`/`resolvedAt` so a "born resolved" + * interrupt is unrepresentable — every interrupt begins pending and only + * `resolve`/`cancel` may move it to a terminal state. + * + * INVARIANT (insert-if-absent): if an interrupt with the same `interruptId` + * already exists, `create` is a **no-op** — it must NOT overwrite the + * existing record. This is the canonical behaviour (SQL backends implement it + * via `ON CONFLICT DO NOTHING` / upsert-with-empty-update), so a duplicate + * create can never clobber a resolved interrupt back to pending. + */ + create: ( + record: Omit, + ) => Promise + /** + * Move an interrupt to `'resolved'`, stamping `resolvedAt` and storing + * `response`. A no-op if `interruptId` does not exist. + */ + resolve: (interruptId: string, response?: unknown) => Promise + /** + * Move an interrupt to `'cancelled'`, stamping `resolvedAt`. A no-op if + * `interruptId` does not exist. + */ + cancel: (interruptId: string) => Promise + /** Return the interrupt for `interruptId`, or `null` if none exists. */ + get: (interruptId: string) => Promise + /** + * All interrupts for a thread. + * + * INVARIANT: ordered by insertion (equivalently `requestedAt` ascending). SQL + * backends MUST `ORDER BY requested_at` — the middleware and testkit rely on + * this stable ordering. + */ + list: (threadId: string) => Promise> + /** Pending interrupts for a thread, ordered by `requestedAt` ascending. */ + listPending: (threadId: string) => Promise> + /** All interrupts for a run, ordered by `requestedAt` ascending. */ + listByRun: (runId: string) => Promise> + /** Pending interrupts for a run, ordered by `requestedAt` ascending. */ + listPendingByRun: (runId: string) => Promise> +} + +/** + * Scoped key/value store for arbitrary JSON metadata. + * + * `(scope, key)` is the composite identity; the same `key` under different + * scopes is independent. + */ +export interface MetadataStore { + /** + * Return the stored value for `(scope, key)`, or `null` if absent. + * + * CAVEAT: the return type is `unknown | null`, where `| null` collapses into + * `unknown` — a stored value of `null` is therefore **indistinguishable from + * absence** at the type level. Callers that must persist a real `null` + * distinctly from "not set" should wrap it (e.g. store `{ value: null }`). + */ + get: (scope: string, key: string) => Promise + /** Insert or overwrite the value for `(scope, key)`. */ + set: (scope: string, key: string, value: unknown) => Promise + /** Remove `(scope, key)`. A no-op if absent. Does not affect other scopes. */ + delete: (scope: string, key: string) => Promise +} + +export interface AIPersistenceStores { + messages?: MessageStore + runs?: RunStore + interrupts?: InterruptStore + metadata?: MetadataStore + locks?: LockStore +} + +export interface AIPersistence< + TStores extends AIPersistenceStores = AIPersistenceStores, +> { + stores: ExactStoreKeys +} + +type StoreKey = keyof AIPersistenceStores +type ExactStoreKeys = + Exclude extends never + ? TStores + : TStores & Record, never> + +export type AIPersistenceOverrides = { + [TKey in StoreKey]?: AIPersistenceStores[TKey] | false +} + +type BaseStoreValue< + TBase extends AIPersistenceStores, + TKey extends StoreKey, +> = TKey extends keyof TBase ? TBase[TKey] : never + +type OverrideStoreValue< + TOverrides extends AIPersistenceOverrides, + TKey extends StoreKey, +> = TKey extends keyof TOverrides ? TOverrides[TKey] : never + +type ResolvedStoreValue< + TBase extends AIPersistenceStores, + TOverrides extends AIPersistenceOverrides, + TKey extends StoreKey, +> = TKey extends keyof TOverrides + ? + | Exclude, false | undefined> + | (undefined extends OverrideStoreValue + ? Exclude, undefined> + : never) + : Exclude, undefined> + +type BaseStoreIsRequired< + TBase extends AIPersistenceStores, + TKey extends StoreKey, +> = TKey extends keyof TBase + ? object extends Pick + ? false + : true + : false + +type ResolvedStoreIsRequired< + TBase extends AIPersistenceStores, + TOverrides extends AIPersistenceOverrides, + TKey extends StoreKey, +> = TKey extends keyof TOverrides + ? false extends OverrideStoreValue + ? false + : undefined extends OverrideStoreValue + ? BaseStoreIsRequired + : true + : BaseStoreIsRequired + +type ResolvedRequiredKeys< + TBase extends AIPersistenceStores, + TOverrides extends AIPersistenceOverrides, +> = { + [TKey in StoreKey]-?: [ResolvedStoreValue] extends [ + never, + ] + ? never + : ResolvedStoreIsRequired extends true + ? TKey + : never +}[StoreKey] + +type ResolvedOptionalKeys< + TBase extends AIPersistenceStores, + TOverrides extends AIPersistenceOverrides, +> = { + [TKey in StoreKey]-?: [ResolvedStoreValue] extends [ + never, + ] + ? never + : ResolvedStoreIsRequired extends true + ? never + : TKey +}[StoreKey] + +type Simplify = { [TKey in keyof T]: T[TKey] } + +export type ComposedAIPersistenceStores< + TBase extends AIPersistenceStores, + TOverrides extends AIPersistenceOverrides, +> = Simplify< + { + [TKey in ResolvedRequiredKeys]: ResolvedStoreValue< + TBase, + TOverrides, + TKey + > + } & { + [TKey in ResolvedOptionalKeys]?: ResolvedStoreValue< + TBase, + TOverrides, + TKey + > + } +> + +const storeKeys = [ + 'messages', + 'runs', + 'interrupts', + 'metadata', + 'locks', +] satisfies Array + +const storeKeySet = new Set(storeKeys) + +function assertKnownStoreKeys(stores: object, location: string): void { + for (const key of Object.keys(stores)) { + if (!storeKeySet.has(key)) { + throw new Error(`Unknown AIPersistence ${location} key: ${key}`) + } + } +} + +export function validatePersistenceStoreKeys(persistence: AIPersistence): void { + assertKnownStoreKeys(persistence.stores, 'store') +} + +export function validateChatPersistenceStores( + persistence: AIPersistence, +): void { + validatePersistenceStoreKeys(persistence) + if (persistence.stores.interrupts && !persistence.stores.runs) { + throw new Error('Chat persistence stores.interrupts requires stores.runs.') + } +} + +export function defineAIPersistence( + persistence: AIPersistence>, +): AIPersistence { + validatePersistenceStoreKeys(persistence) + return persistence +} + +export function composePersistence< + TBase extends AIPersistenceStores, + TOverrides extends AIPersistenceOverrides, +>( + base: AIPersistence, + config: { + overrides: ExactStoreKeys + }, +): AIPersistence> +export function composePersistence( + base: AIPersistence, + config: { overrides: AIPersistenceOverrides }, +): AIPersistence { + validatePersistenceStoreKeys(base) + assertKnownStoreKeys(config.overrides, 'override') + + const stores: AIPersistenceStores = { ...base.stores } + for (const key of storeKeys) { + if (!Object.prototype.hasOwnProperty.call(config.overrides, key)) continue + const override = config.overrides[key] + if (override === false) { + delete stores[key] + } else if (override !== undefined) { + setStore(stores, key, override) + } + } + return { stores } +} + +function setStore( + stores: AIPersistenceStores, + key: TKey, + value: NonNullable, +): void { + stores[key] = value +} diff --git a/packages/ai-persistence/tests/capabilities.test.ts b/packages/ai-persistence/tests/capabilities.test.ts new file mode 100644 index 000000000..cbf83d8c2 --- /dev/null +++ b/packages/ai-persistence/tests/capabilities.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest' +import { EventType, chat, defineChatMiddleware } from '@tanstack/ai' +import type { + AnyTextAdapter, + ChatMiddlewareContext, + LockStore, + StreamChunk, +} from '@tanstack/ai' +import { memoryPersistence } from '../src/memory' +import { withChatPersistence } from '../src/middleware' +import { + InterruptsCapability, + LocksCapability, + PersistenceCapability, + getInterrupts, + getLocks, + getPersistence, +} from '../src/capabilities' +import { createInterruptController } from '../src/interrupts' +import type { AIPersistence, InterruptStore } from '../src' + +function mockAdapter(chunks: Array) { + return { + kind: 'text', + name: 'mock', + model: 'test-model', + '~types': {}, + chatStream: () => + (async function* () { + for (const c of chunks) yield c + })(), + structuredOutput: async () => ({ data: {}, rawText: '{}' }), + } as unknown as AnyTextAdapter +} + +async function collect(stream: AsyncIterable) { + const out: Array = [] + for await (const c of stream) out.push(c) + return out +} + +describe('persistence capabilities', () => { + it('provides persistence, interrupts, and locks to downstream middleware', async () => { + const persistence = memoryPersistence() + const seen: { + persistence?: AIPersistence + interrupts?: InterruptStore + locks?: LockStore + } = {} + + const consumer = defineChatMiddleware({ + name: 'capability-consumer', + requires: [PersistenceCapability, InterruptsCapability, LocksCapability], + setup(ctx: ChatMiddlewareContext) { + seen.persistence = getPersistence(ctx) + seen.interrupts = getInterrupts(ctx) + seen.locks = getLocks(ctx) + }, + }) + + await collect( + chat({ + adapter: mockAdapter([ + { + type: EventType.RUN_STARTED, + runId: 'r1', + threadId: 't1', + timestamp: 1, + }, + { + type: EventType.RUN_FINISHED, + runId: 'r1', + threadId: 't1', + finishReason: 'stop', + timestamp: 1, + }, + ]), + messages: [{ role: 'user', content: 'hi' }], + runId: 'r1', + threadId: 't1', + middleware: [withChatPersistence(persistence), consumer], + }) as AsyncIterable, + ) + + expect(seen.persistence).toBe(persistence) + expect(seen.interrupts).toBe(persistence.stores.interrupts) + expect(seen.locks).toBe(persistence.stores.locks) + }) +}) + +describe('createInterruptController', () => { + it('delegates request/resolve/cancel/list to the underlying store', async () => { + const persistence = memoryPersistence() + const controller = createInterruptController({ + store: persistence.stores.interrupts!, + }) + + await controller.request({ + interruptId: 'c1', + runId: 'run-c', + threadId: 'thread-c', + requestedAt: 1, + payload: { kind: 'approval' }, + }) + expect(await controller.listPending('thread-c')).toHaveLength(1) + expect(await controller.listPendingByRun('run-c')).toHaveLength(1) + + await controller.resolve('c1', { approved: true }) + expect((await persistence.stores.interrupts!.get('c1'))?.status).toBe( + 'resolved', + ) + expect((await persistence.stores.interrupts!.get('c1'))?.response).toEqual({ + approved: true, + }) + expect(await controller.listPending('thread-c')).toHaveLength(0) + + await controller.request({ + interruptId: 'c2', + runId: 'run-c', + threadId: 'thread-c', + requestedAt: 2, + payload: {}, + }) + await controller.cancel('c2') + expect((await persistence.stores.interrupts!.get('c2'))?.status).toBe( + 'cancelled', + ) + }) + + it('creates interrupts in the pending state', async () => { + const persistence = memoryPersistence() + const controller = createInterruptController({ + store: persistence.stores.interrupts!, + }) + await controller.request({ + interruptId: 'c1', + runId: 'run-c', + threadId: 'thread-c', + requestedAt: 1, + payload: {}, + }) + expect((await persistence.stores.interrupts!.get('c1'))?.status).toBe( + 'pending', + ) + }) +}) diff --git a/packages/ai-persistence/tests/error-abort.test.ts b/packages/ai-persistence/tests/error-abort.test.ts new file mode 100644 index 000000000..f1d5a2639 --- /dev/null +++ b/packages/ai-persistence/tests/error-abort.test.ts @@ -0,0 +1,285 @@ +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 { + withChatPersistence, + 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: [withChatPersistence(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: [withChatPersistence(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: [withChatPersistence(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: [withChatPersistence(persistence)], + }) as AsyncIterable, + ), + ).rejects.toThrow('saveThread failed') + + // The interrupt was created before the failing snapshot, so it survives: + // recovery/retry can still see the pending interrupt. + const pending = await base.stores.interrupts!.listPending('t1') + expect(pending.map((p) => p.interruptId)).toEqual(['interrupt-1']) + }) +}) + +function imageAdapterThatThrows(thrown: unknown): ImageAdapter { + return { + kind: 'image', + name: 'test-image-provider', + model: 'test-image-model', + '~types': { + providerOptions: {}, + modelProviderOptionsByName: {}, + modelSizeByName: {}, + modelInputModalitiesByName: {}, + }, + generateImages: vi.fn(() => Promise.reject(thrown)), + } +} + +describe('generation persistence error/abort hooks', () => { + it('marks the run failed when generation throws', async () => { + const persistence = memoryPersistence() + + await expect( + generateImage({ + adapter: imageAdapterThatThrows(new Error('image boom')), + prompt: 'make an image', + threadId: 't-img', + runId: 'r-img', + middleware: [withGenerationPersistence(persistence)], + }), + ).rejects.toThrow('image boom') + + const run = await persistence.stores.runs!.get('r-img') + 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() + + await expect( + generateImage({ + adapter: imageAdapterThatThrows('image string failure'), + prompt: 'make an image', + threadId: 't-img', + runId: 'r-img', + middleware: [withGenerationPersistence(persistence)], + }), + ).rejects.toBeDefined() + + const run = await persistence.stores.runs!.get('r-img') + 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: 'r-abort', + threadId: 't-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 ctx = { + runId: 'r-abort', + threadId: 't-abort', + requestId: 'req-abort', + } as GenerationMiddlewareContext + await middleware.onAbort?.(ctx, { + duration: 1, + reason: 'client cancelled', + } as GenerationAbortInfo) + + expect((await persistence.stores.runs!.get('r-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: 'r-err', + threadId: 't-err', + startedAt: 1, + }) + const ctx = { + runId: 'r-err', + threadId: 't-err', + requestId: 'req-err', + } as GenerationMiddlewareContext + await middleware.onError?.(ctx, { + error: { code: 500 }, + duration: 1, + } as unknown as GenerationErrorInfo) + + const run = await persistence.stores.runs!.get('r-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..302660d2c --- /dev/null +++ b/packages/ai-persistence/tests/interrupts.test.ts @@ -0,0 +1,867 @@ +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 { withChatPersistence } 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 = (): StreamChunk => ({ + type: EventType.RUN_FINISHED, + runId: 'r1', + 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: [withChatPersistence(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: [withChatPersistence(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: [withChatPersistence(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: [withChatPersistence(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: [withChatPersistence(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: [withChatPersistence(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') + }) + + 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: [withChatPersistence(persistence)], + }) as AsyncIterable, + ) + + const approvalInterrupt = await persistence.stores.interrupts!.get( + 'approval_tool-call-1', + ) + expect(approvalInterrupt?.status).toBe('pending') + + const afterApproval = mockAdapter([toolCallChunks()]) + 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: [withChatPersistence(persistence)], + }) as AsyncIterable, + ) + + expect(afterApproval.calls).toHaveLength(1) + expect( + ( + afterApproval.calls[0] as { approvals?: ReadonlyMap } + ).approvals?.get('approval_tool-call-1'), + ).toBe(true) + expect( + approvalChunks.find( + (chunk) => + chunk.type === EventType.RUN_FINISHED && + chunk.outcome?.type === 'interrupt', + ), + ).toMatchObject({ + outcome: { + interrupts: [ + { + id: 'client_tool_tool-call-1', + reason: 'client_tool_input', + 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([ + toolCallChunks(), + [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: [withChatPersistence(persistence)], + }) as AsyncIterable, + ) + + expect(afterClientTool.calls).toHaveLength(2) + 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: [withChatPersistence(persistence)], + }) as AsyncIterable, + ) + + const continuation = mockAdapter([[text('SHOULD NOT RUN')]]) + await expect( + collect( + chat({ + adapter: continuation.adapter, + messages: [], + runId: 'r1', + threadId: 't1', + resume: [], + middleware: [withChatPersistence(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: [withChatPersistence(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: [withChatPersistence(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: [withChatPersistence(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: [withChatPersistence(persistence)], + }) as AsyncIterable, + ), + ).rejects.toThrow(/missing resume entry.*interrupt-1/i) + + const good = mockAdapter([ + [runStarted(), { ...interruptFinished(), runId: 'r2' }], + ]) + await collect( + chat({ + adapter: good.adapter, + messages: [{ role: 'user', content: 'new input' }], + runId: 'r2', + threadId: 't1', + resume: [{ interruptId: 'interrupt-1', status: 'resolved' }], + middleware: [withChatPersistence(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: [withChatPersistence(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: [withChatPersistence(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: [withChatPersistence(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: [withChatPersistence(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: [withChatPersistence(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: [withChatPersistence(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: [withChatPersistence(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: [withChatPersistence(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: [withChatPersistence(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: [withChatPersistence(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: [withChatPersistence(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: [withChatPersistence(persistence)], + }) as AsyncIterable, + ) + + // The run is unaffected (no approval/client-tool detected) and the resume is + // still committed on success. + expect(run.calls).toHaveLength(1) + expect( + (run.calls[0] as { approvals?: ReadonlyMap }).approvals + ?.size ?? 0, + ).toBe(0) + expect((await persistence.stores.interrupts!.get('weird-1'))?.status).toBe( + 'resolved', + ) + }) +}) diff --git a/packages/ai-persistence/tests/memory.conformance.test.ts b/packages/ai-persistence/tests/memory.conformance.test.ts new file mode 100644 index 000000000..0b382b6a0 --- /dev/null +++ b/packages/ai-persistence/tests/memory.conformance.test.ts @@ -0,0 +1,4 @@ +import { runPersistenceConformance } from '../src/testkit/conformance' +import { memoryPersistence } from '../src/memory' + +runPersistenceConformance('memory', () => memoryPersistence()) diff --git a/packages/ai-persistence/tests/memory.test.ts b/packages/ai-persistence/tests/memory.test.ts new file mode 100644 index 000000000..560069ecf --- /dev/null +++ b/packages/ai-persistence/tests/memory.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest' +import { memoryPersistence } from '../src/memory' +import { defineAIPersistence } from '../src/types' + +describe('memoryPersistence', () => { + it('returns a namespaced AIPersistence with every state store present', () => { + const p = memoryPersistence() + expect(p.stores.messages).toBeDefined() + expect(p.stores.runs).toBeDefined() + expect(p.stores.interrupts).toBeDefined() + expect(p.stores.locks).toBeDefined() + }) + + it('exposes the complete state store set', () => { + expect(Object.keys(memoryPersistence().stores).sort()).toEqual([ + 'interrupts', + 'locks', + 'messages', + 'metadata', + 'runs', + ]) + }) + + it('defineAIPersistence is an identity helper', () => { + const persistence = memoryPersistence() + expect(defineAIPersistence(persistence)).toBe(persistence) + }) + + describe('runs', () => { + it('createOrResume is idempotent and update patches status', async () => { + const { runs } = memoryPersistence().stores + const a = await runs!.createOrResume({ + runId: 'r1', + threadId: 't1', + startedAt: 100, + }) + expect(a.status).toBe('running') + // Resume returns the SAME record, not a fresh one. + const b = await runs!.createOrResume({ + runId: 'r1', + threadId: 't1', + startedAt: 999, + }) + expect(b.startedAt).toBe(100) + + await runs!.update('r1', { status: 'completed', finishedAt: 200 }) + const got = await runs!.get('r1') + expect(got?.status).toBe('completed') + expect(got?.finishedAt).toBe(200) + }) + }) + + describe('messages', () => { + it('round-trips a thread transcript', async () => { + const { messages } = memoryPersistence().stores + expect(await messages!.loadThread('t1')).toEqual([]) + await messages!.saveThread('t1', [{ role: 'user', content: 'hi' }]) + expect(await messages!.loadThread('t1')).toEqual([ + { role: 'user', content: 'hi' }, + ]) + }) + }) + + describe('interrupts', () => { + it('creates, resolves, and lists pending interrupts by thread', async () => { + const { interrupts } = memoryPersistence().stores + await interrupts!.create({ + interruptId: 'i1', + runId: 'r1', + threadId: 't1', + requestedAt: 1, + payload: { kind: 'approval' }, + }) + expect((await interrupts!.get('i1'))?.status).toBe('pending') + expect(await interrupts!.listPending('t1')).toHaveLength(1) + await interrupts!.resolve('i1', { action: 'approve' }) + expect((await interrupts!.get('i1'))?.status).toBe('resolved') + expect(await interrupts!.listPending('t1')).toHaveLength(0) + }) + + it('lists interrupts and pending interrupts by run', async () => { + const { interrupts } = memoryPersistence().stores + await interrupts!.create({ + interruptId: 'i1', + runId: 'r1', + threadId: 't1', + requestedAt: 1, + payload: { kind: 'approval' }, + }) + await interrupts!.create({ + interruptId: 'i2', + runId: 'r1', + threadId: 't2', + requestedAt: 2, + payload: { kind: 'input' }, + }) + await interrupts!.create({ + interruptId: 'i3', + runId: 'r2', + threadId: 't1', + requestedAt: 3, + payload: { kind: 'other' }, + }) + + await interrupts!.resolve('i2') + + expect( + (await interrupts!.listByRun('r1')).map( + (interrupt) => interrupt.interruptId, + ), + ).toEqual(['i1', 'i2']) + expect( + (await interrupts!.listPendingByRun('r1')).map( + (interrupt) => interrupt.interruptId, + ), + ).toEqual(['i1']) + }) + }) + + describe('metadata', () => { + it('returns null for missing metadata and preserves stored undefined', async () => { + const { metadata } = memoryPersistence().stores + expect(await metadata!.get('scope', 'missing')).toBeNull() + + await metadata!.set('scope', 'present', undefined) + expect(await metadata!.get('scope', 'present')).toBeUndefined() + + await metadata!.delete('scope', 'present') + expect(await metadata!.get('scope', 'present')).toBeNull() + }) + }) +}) diff --git a/packages/ai-persistence/tests/persistence-composition.test.ts b/packages/ai-persistence/tests/persistence-composition.test.ts new file mode 100644 index 000000000..07bf755bb --- /dev/null +++ b/packages/ai-persistence/tests/persistence-composition.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest' +import { composePersistence, defineAIPersistence } from '../src' +import { + createInterruptStore, + createMessageStore, + createMetadataStore, + createRunStore, +} from './persistence-fixtures' + +describe('composePersistence', () => { + it('replaces only the named store', () => { + const baseMessages = createMessageStore() + const overrideMessages = createMessageStore() + const runs = createRunStore() + const base = defineAIPersistence({ + stores: { messages: baseMessages, runs }, + }) + + const composed = composePersistence(base, { + overrides: { messages: overrideMessages }, + }) + + expect(composed.stores.messages).toBe(overrideMessages) + expect(composed.stores.runs).toBe(runs) + }) + + it('applies multiple overrides independently', () => { + const base = defineAIPersistence({ + stores: { + messages: createMessageStore(), + runs: createRunStore(), + interrupts: createInterruptStore(), + }, + }) + const messages = createMessageStore() + const runs = createRunStore() + + const composed = composePersistence(base, { + overrides: { messages, runs }, + }) + + expect(composed.stores.messages).toBe(messages) + expect(composed.stores.runs).toBe(runs) + expect(composed.stores.interrupts).toBe(base.stores.interrupts) + }) + + it('removes each store explicitly overridden with false', () => { + const base = defineAIPersistence({ + stores: { + messages: createMessageStore(), + runs: createRunStore(), + interrupts: createInterruptStore(), + }, + }) + + const composed = composePersistence(base, { + overrides: { runs: false, interrupts: false }, + }) + + expect('runs' in composed.stores).toBe(false) + expect('interrupts' in composed.stores).toBe(false) + expect(composed.stores.messages).toBe(base.stores.messages) + expect(base.stores.runs).toBeDefined() + expect(base.stores.interrupts).toBeDefined() + }) + + it('inherits omitted and explicitly undefined stores from the base', () => { + const messages = createMessageStore() + const runs = createRunStore() + const metadata = createMetadataStore() + const base = defineAIPersistence({ stores: { messages, runs, metadata } }) + + const composed = composePersistence(base, { + overrides: { messages: undefined, metadata: createMetadataStore() }, + }) + + expect(composed.stores.messages).toBe(messages) + expect(composed.stores.runs).toBe(runs) + expect(composed.stores.metadata).not.toBe(metadata) + }) + + it('does not mutate or assume ownership of base and override resources', () => { + let baseDisposeCalls = 0 + let overrideDisposeCalls = 0 + const baseMessages = { + ...createMessageStore(), + dispose: () => { + baseDisposeCalls += 1 + }, + } + const overrideMessages = { + ...createMessageStore(), + dispose: () => { + overrideDisposeCalls += 1 + }, + } + const base = defineAIPersistence({ + stores: { messages: baseMessages, runs: createRunStore() }, + }) + const overrides = { messages: overrideMessages } + Object.freeze(base.stores) + Object.freeze(overrides) + + const composed = composePersistence(base, { overrides }) + + expect(composed).not.toBe(base) + expect(composed.stores).not.toBe(base.stores) + expect(base.stores.messages).toBe(baseMessages) + expect(overrides.messages).toBe(overrideMessages) + expect(baseDisposeCalls).toBe(0) + expect(overrideDisposeCalls).toBe(0) + }) + + it('rejects unknown override keys received from untyped JavaScript', () => { + const base = defineAIPersistence({ + stores: { messages: createMessageStore() }, + }) + const overrides = { messages: createMessageStore() } + Reflect.set(overrides, 'unknownStore', createRunStore()) + + expect(() => composePersistence(base, { overrides })).toThrow( + /unknown.*unknownStore/i, + ) + }) + + it('rejects unknown base store keys received from untyped JavaScript', () => { + const stores = { messages: createMessageStore() } + Reflect.set(stores, 'unknownStore', createRunStore()) + + expect(() => defineAIPersistence({ stores })).toThrow( + /unknown.*unknownStore/i, + ) + }) + + it('routes calls only to the selected store', async () => { + const baseCalls: Array = [] + const overrideCalls: Array = [] + const base = defineAIPersistence({ + stores: { + messages: createMessageStore((threadId) => baseCalls.push(threadId)), + }, + }) + const overrideMessages = createMessageStore((threadId) => + overrideCalls.push(threadId), + ) + const composed = composePersistence(base, { + overrides: { messages: overrideMessages }, + }) + + await composed.stores.messages.saveThread('thread-1', []) + + expect(overrideCalls).toEqual(['thread-1']) + expect(baseCalls).toEqual([]) + }) +}) diff --git a/packages/ai-persistence/tests/persistence-fixtures.ts b/packages/ai-persistence/tests/persistence-fixtures.ts new file mode 100644 index 000000000..f86ec7ce5 --- /dev/null +++ b/packages/ai-persistence/tests/persistence-fixtures.ts @@ -0,0 +1,64 @@ +import type { + InterruptStore, + MessageStore, + MetadataStore, + RunRecord, + RunStore, +} from '../src' + +export function createMessageStore( + onSave?: (threadId: string) => void, +): MessageStore { + return { + loadThread: () => Promise.resolve([]), + saveThread: (threadId) => { + onSave?.(threadId) + return Promise.resolve() + }, + } +} + +export function createRunStore(): RunStore { + const runs = new Map() + return { + createOrResume: (input) => { + const existing = runs.get(input.runId) + if (existing) return Promise.resolve(existing) + const record: RunRecord = { + runId: input.runId, + threadId: input.threadId, + status: input.status ?? 'running', + startedAt: input.startedAt, + } + runs.set(record.runId, record) + return Promise.resolve(record) + }, + update: (runId, patch) => { + const existing = runs.get(runId) + if (existing) runs.set(runId, { ...existing, ...patch }) + return Promise.resolve() + }, + get: (runId) => Promise.resolve(runs.get(runId) ?? null), + } +} + +export function createInterruptStore(): InterruptStore { + return { + create: () => Promise.resolve(), + resolve: () => Promise.resolve(), + cancel: () => Promise.resolve(), + get: () => Promise.resolve(null), + list: () => Promise.resolve([]), + listPending: () => Promise.resolve([]), + listByRun: () => Promise.resolve([]), + listPendingByRun: () => Promise.resolve([]), + } +} + +export function createMetadataStore(): MetadataStore { + return { + get: () => Promise.resolve(null), + set: () => Promise.resolve(), + delete: () => Promise.resolve(), + } +} diff --git a/packages/ai-persistence/tests/persistence-types.test-d.ts b/packages/ai-persistence/tests/persistence-types.test-d.ts new file mode 100644 index 000000000..f27a2444e --- /dev/null +++ b/packages/ai-persistence/tests/persistence-types.test-d.ts @@ -0,0 +1,142 @@ +import { expectTypeOf } from 'vitest' +import { + composePersistence, + defineAIPersistence, + memoryPersistence, + withChatPersistence, + withGenerationPersistence, +} from '../src' +import type { LockStore } from '@tanstack/ai' +import type { + AIPersistence, + InterruptStore, + MessageStore, + MetadataStore, + RunStore, +} from '../src' + +declare const messages: MessageStore +declare const replacementMessages: MessageStore & { + readonly source: 'override-messages' +} +declare const runs: RunStore +declare const replacementRuns: RunStore & { + readonly source: 'override-runs' +} +declare const interrupts: InterruptStore +declare const metadata: MetadataStore +declare const locks: LockStore + +const messagesOnly = defineAIPersistence({ stores: { messages } }) +expectTypeOf(messagesOnly).toEqualTypeOf< + AIPersistence<{ messages: MessageStore }> +>() +expectTypeOf(messagesOnly.stores).toEqualTypeOf<{ + messages: MessageStore +}>() +// @ts-expect-error exact persistence types do not invent absent stores +messagesOnly.stores.runs + +// @ts-expect-error persistence aggregates accept only registered store keys +defineAIPersistence({ stores: { unknownStore: messages } }) + +type InvalidExplicitStores = { + messages: MessageStore + unknownStore: MessageStore +} +const invalidExplicitPersistence: AIPersistence = { + // @ts-expect-error explicit AIPersistence store maps are exact too + stores: { messages, unknownStore: messages }, +} +void invalidExplicitPersistence + +const base = defineAIPersistence({ + stores: { messages, runs, interrupts, metadata }, +}) + +const replaced = composePersistence(base, { + overrides: { messages: replacementMessages }, +}) +expectTypeOf(replaced.stores).toEqualTypeOf<{ + messages: typeof replacementMessages + runs: RunStore + interrupts: InterruptStore + metadata: MetadataStore +}>() + +const multiple = composePersistence(base, { + overrides: { messages: replacementMessages, runs: replacementRuns }, +}) +expectTypeOf(multiple.stores.messages).toEqualTypeOf< + typeof replacementMessages +>() +expectTypeOf(multiple.stores.runs).toEqualTypeOf() +expectTypeOf(multiple.stores.interrupts).toEqualTypeOf() + +// @ts-expect-error persistence overrides accept only registered store keys +composePersistence(base, { overrides: { unknownStore: messages } }) + +const removed = composePersistence(base, { + overrides: { runs: false, interrupts: false }, +}) +expectTypeOf(removed.stores).toEqualTypeOf<{ + messages: MessageStore + metadata: MetadataStore +}>() +// @ts-expect-error false removes the store from the exact result +removed.stores.runs +// @ts-expect-error false removes the store from the exact result +removed.stores.interrupts + +const inherited = composePersistence(base, { + overrides: { messages: undefined }, +}) +expectTypeOf(inherited.stores.messages).toEqualTypeOf() +expectTypeOf(inherited.stores.runs).toEqualTypeOf() + +declare const uncertainRemoval: MessageStore | false +const uncertain = composePersistence(base, { + overrides: { messages: uncertainRemoval }, +}) +expectTypeOf(uncertain.stores.messages).toEqualTypeOf< + MessageStore | undefined +>() +expectTypeOf(uncertain.stores.runs).toEqualTypeOf() + +declare const uncertainReplacement: MessageStore | undefined +const uncertainInherited = composePersistence(base, { + overrides: { messages: uncertainReplacement }, +}) +expectTypeOf(uncertainInherited.stores.messages).toEqualTypeOf() + +withChatPersistence(messagesOnly) +withChatPersistence(defineAIPersistence({ stores: { runs } })) +withChatPersistence( + defineAIPersistence({ stores: { runs, interrupts, messages } }), +) +// @ts-expect-error a known interrupt store requires a known run store +withChatPersistence(defineAIPersistence({ stores: { interrupts } })) + +withGenerationPersistence(defineAIPersistence({ stores: { runs } })) + +const chatWithRemovedRuns = composePersistence(base, { + overrides: { runs: false }, +}) +// @ts-expect-error composition carries the missing run dependency into chat +withChatPersistence(chatWithRemovedRuns) + +declare const broadPersistence: AIPersistence +declare const dynamicChatPersistence: AIPersistence<{ + runs?: RunStore + interrupts?: InterruptStore +}> +withChatPersistence(broadPersistence) +withChatPersistence(dynamicChatPersistence) +withGenerationPersistence(broadPersistence) + +const memoryWithCustomLocks = composePersistence(memoryPersistence(), { + overrides: { locks }, +}) +expectTypeOf(memoryWithCustomLocks.stores.locks).toEqualTypeOf() +const mutableMemoryPersistence = memoryPersistence() +mutableMemoryPersistence.stores.locks = locks diff --git a/packages/ai-persistence/tests/persistence-validation.test.ts b/packages/ai-persistence/tests/persistence-validation.test.ts new file mode 100644 index 000000000..12c23adc5 --- /dev/null +++ b/packages/ai-persistence/tests/persistence-validation.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { withChatPersistence } from '../src/middleware' +import type { AIPersistence } from '../src' +import { + createInterruptStore, + createMessageStore, + createRunStore, +} from './persistence-fixtures' + +describe('persistence store dependency validation', () => { + it('rejects a dynamic chat persistence with interrupts but no runs', () => { + const persistence: AIPersistence = { + stores: { interrupts: createInterruptStore() }, + } + + expect(() => withChatPersistence(persistence)).toThrow( + /interrupts.*stores\.runs/i, + ) + }) + + it('allows independent message and run stores for chat', () => { + const messages: AIPersistence = { + stores: { messages: createMessageStore() }, + } + const runs: AIPersistence = { stores: { runs: createRunStore() } } + + expect(() => withChatPersistence(messages)).not.toThrow() + expect(() => withChatPersistence(runs)).not.toThrow() + }) + + it('allows a dynamic chat persistence with paired run and interrupt stores', () => { + const persistence: AIPersistence = { + stores: { + runs: createRunStore(), + interrupts: createInterruptStore(), + }, + } + + expect(() => withChatPersistence(persistence)).not.toThrow() + }) +}) diff --git a/packages/ai-persistence/tests/state-only.test.ts b/packages/ai-persistence/tests/state-only.test.ts new file mode 100644 index 000000000..63ac63be3 --- /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 { withChatPersistence } 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: [withChatPersistence(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: [withChatPersistence(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..3065ae8ba --- /dev/null +++ b/packages/ai-persistence/tests/with-persistence.test.ts @@ -0,0 +1,268 @@ +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 { withChatPersistence } 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('withChatPersistence (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: [withChatPersistence(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 transcript is saved. + expect((await persistence.stores.runs!.get('r1'))?.status).toBe('completed') + expect( + (await persistence.stores.messages!.loadThread('t1')).length, + ).toBeGreaterThan(0) + }) + + 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: [withChatPersistence(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: [withChatPersistence(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: [withChatPersistence(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: [withChatPersistence(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: [withChatPersistence(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: [withChatPersistence(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', + resume: [ + { + interruptId: 'interrupt-1', + status: 'resolved', + payload: { approved: true }, + }, + ], + middleware: [withChatPersistence(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: [withChatPersistence(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/pnpm-lock.yaml b/pnpm-lock.yaml index 9812eaf84..63d35dbed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -357,7 +357,7 @@ importers: version: 17.2.3 nitro: specifier: 3.0.260610-beta - version: 3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0) + version: 3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0) tailwindcss: specifier: ^4.1.18 version: 4.1.18 @@ -514,7 +514,7 @@ importers: version: 15.0.12 nitro: specifier: 3.0.260610-beta - version: 3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0) + version: 3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0) puppeteer: specifier: ^24.34.0 version: 24.39.1(supports-color@7.2.0)(typescript@5.9.3) @@ -608,7 +608,7 @@ importers: version: 0.10.0 nitro: specifier: 3.0.260610-beta - version: 3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0) + version: 3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0) react: specifier: ^19.2.3 version: 19.2.3 @@ -742,6 +742,12 @@ importers: '@tanstack/ai-openrouter': specifier: workspace:* version: link:../../packages/ai-openrouter + '@tanstack/ai-persistence': + specifier: workspace:* + version: link:../../packages/ai-persistence + '@tanstack/ai-persistence-drizzle': + specifier: workspace:* + version: link:../../packages/ai-persistence-drizzle '@tanstack/ai-react': specifier: workspace:* version: link:../../packages/ai-react @@ -798,7 +804,7 @@ importers: version: 0.561.0(react@19.2.3) nitro: specifier: 3.0.260610-beta - version: 3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0) + version: 3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0) react: specifier: ^19.2.3 version: 19.2.3 @@ -901,7 +907,7 @@ importers: version: 0.561.0(react@19.2.3) nitro: specifier: 3.0.260610-beta - version: 3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0) + version: 3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0) react: specifier: ^19.2.3 version: 19.2.3 @@ -1035,7 +1041,7 @@ importers: version: 0.561.0(react@19.2.3) nitro: specifier: 3.0.260610-beta - version: 3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0) + version: 3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0) radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -1132,7 +1138,7 @@ importers: version: link:../../packages/ai-solid-ui '@tanstack/nitro-v2-vite-plugin': specifier: ^1.155.0 - version: 1.155.0(aws4fetch@1.0.20)(rolldown@1.1.5)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 1.155.0(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3)))(rolldown@1.1.5)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: ^1.158.4 version: 1.159.5(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) @@ -2043,6 +2049,78 @@ importers: specifier: ^4.2.0 version: 4.3.6 + packages/ai-persistence: + devDependencies: + '@tanstack/ai': + specifier: workspace:* + version: link:../ai + '@vitest/coverage-v8': + specifier: 4.0.14 + version: 4.0.14(supports-color@7.2.0)(vitest@4.1.10) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.0.14)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.19))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) + + packages/ai-persistence-cloudflare: + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20260317.1 + version: 4.20260317.1 + '@tanstack/ai': + specifier: workspace:* + version: link:../ai + '@tanstack/ai-persistence': + specifier: workspace:* + version: link:../ai-persistence + '@tanstack/ai-persistence-drizzle': + specifier: workspace:* + version: link:../ai-persistence-drizzle + '@vitest/coverage-v8': + specifier: 4.0.14 + version: 4.0.14(supports-color@7.2.0)(vitest@4.1.10) + drizzle-orm: + specifier: ^0.45.0 + version: 0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@7.0.2))(typescript@7.0.2))(prisma@6.19.3(typescript@7.0.2)) + miniflare: + specifier: ^4.20260609.0 + version: 4.20260617.1 + + packages/ai-persistence-drizzle: + devDependencies: + '@tanstack/ai': + specifier: workspace:* + version: link:../ai + '@tanstack/ai-persistence': + specifier: workspace:* + version: link:../ai-persistence + '@vitest/coverage-v8': + specifier: 4.0.14 + version: 4.0.14(supports-color@7.2.0)(vitest@4.1.10) + drizzle-kit: + specifier: ^0.31.0 + version: 0.31.10 + drizzle-orm: + specifier: ^0.45.0 + version: 0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@7.0.2))(typescript@7.0.2))(prisma@6.19.3(typescript@7.0.2)) + + packages/ai-persistence-prisma: + devDependencies: + '@prisma/client': + specifier: ^6.19.3 + version: 6.19.3(prisma@6.19.3(typescript@7.0.2))(typescript@7.0.2) + '@tanstack/ai': + specifier: workspace:* + version: link:../ai + '@tanstack/ai-persistence': + specifier: workspace:* + version: link:../ai-persistence + '@vitest/coverage-v8': + specifier: 4.0.14 + version: 4.0.14(supports-color@7.2.0)(vitest@4.1.10) + prisma: + specifier: ^6.19.3 + version: 6.19.3(typescript@7.0.2) + packages/ai-preact: dependencies: '@tanstack/ai-client': @@ -2607,7 +2685,7 @@ importers: version: 0.4.1 '@tanstack/nitro-v2-vite-plugin': specifier: ^1.155.0 - version: 1.155.0(aws4fetch@1.0.20)(rolldown@1.1.5)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 1.155.0(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(rolldown@1.1.5)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) '@tanstack/react-ai-devtools': specifier: workspace:* version: link:../../packages/react-ai-devtools @@ -2716,7 +2794,7 @@ importers: version: link:../../packages/ai-react-ui '@tanstack/nitro-v2-vite-plugin': specifier: ^1.155.0 - version: 1.155.0(aws4fetch@1.0.20)(rolldown@1.1.5)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 1.155.0(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(rolldown@1.1.5)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) '@tanstack/react-router': specifier: ^1.158.4 version: 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -2725,7 +2803,7 @@ importers: version: 1.159.5(crossws@0.4.6(srvx@0.11.17))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) '@tanstack/start': specifier: ^1.120.20 - version: 1.120.20(@types/node@24.10.3)(aws4fetch@1.0.20)(crossws@0.4.6(srvx@0.11.17))(db0@0.3.4)(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0) + version: 1.120.20(@types/node@24.10.3)(aws4fetch@1.0.20)(crossws@0.4.6(srvx@0.11.17))(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0) highlight.js: specifier: ^11.11.1 version: 11.11.1 @@ -3917,6 +3995,9 @@ packages: '@deno/shim-deno@0.19.2': resolution: {integrity: sha512-q3VTHl44ad8T2Tw2SpeAvghdGOjlnLPDNO2cpOxwMrBE/PVas6geWpbpIgrM+czOCH0yejp0yi8OaTuB+NU40Q==} + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + '@elevenlabs/client@1.3.1': resolution: {integrity: sha512-bQUxA/X7TZRSSZ6UM6a6A+1qQy5Wh7vMn+zbZP6Yl1WrupxHL4M0XMnl/n9+fsol1Ib4tN/2Nhx1E5JDS7QdKw==} @@ -3951,6 +4032,14 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + '@esbuild/aix-ppc64@0.20.2': resolution: {integrity: sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==} engines: {node: '>=12'} @@ -3981,6 +4070,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.20.2': resolution: {integrity: sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==} engines: {node: '>=12'} @@ -4011,6 +4106,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.20.2': resolution: {integrity: sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==} engines: {node: '>=12'} @@ -4041,6 +4142,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.20.2': resolution: {integrity: sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==} engines: {node: '>=12'} @@ -4071,6 +4178,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.20.2': resolution: {integrity: sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==} engines: {node: '>=12'} @@ -4101,6 +4214,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.20.2': resolution: {integrity: sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==} engines: {node: '>=12'} @@ -4131,6 +4250,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.20.2': resolution: {integrity: sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==} engines: {node: '>=12'} @@ -4161,6 +4286,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.20.2': resolution: {integrity: sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==} engines: {node: '>=12'} @@ -4191,6 +4322,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.20.2': resolution: {integrity: sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==} engines: {node: '>=12'} @@ -4221,6 +4358,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.20.2': resolution: {integrity: sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==} engines: {node: '>=12'} @@ -4251,6 +4394,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.20.2': resolution: {integrity: sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==} engines: {node: '>=12'} @@ -4281,6 +4430,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.20.2': resolution: {integrity: sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==} engines: {node: '>=12'} @@ -4311,6 +4466,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.20.2': resolution: {integrity: sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==} engines: {node: '>=12'} @@ -4341,6 +4502,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.20.2': resolution: {integrity: sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==} engines: {node: '>=12'} @@ -4371,6 +4538,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.20.2': resolution: {integrity: sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==} engines: {node: '>=12'} @@ -4401,6 +4574,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.20.2': resolution: {integrity: sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==} engines: {node: '>=12'} @@ -4431,6 +4610,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.20.2': resolution: {integrity: sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==} engines: {node: '>=12'} @@ -4485,6 +4670,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.20.2': resolution: {integrity: sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==} engines: {node: '>=12'} @@ -4539,6 +4730,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.20.2': resolution: {integrity: sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==} engines: {node: '>=12'} @@ -4593,6 +4790,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.20.2': resolution: {integrity: sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==} engines: {node: '>=12'} @@ -4623,6 +4826,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.20.2': resolution: {integrity: sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==} engines: {node: '>=12'} @@ -4653,6 +4862,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.20.2': resolution: {integrity: sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==} engines: {node: '>=12'} @@ -4683,6 +4898,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.20.2': resolution: {integrity: sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==} engines: {node: '>=12'} @@ -6665,6 +6886,36 @@ packages: '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@prisma/client@6.19.3': + resolution: {integrity: sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==} + engines: {node: '>=18.18'} + peerDependencies: + prisma: '*' + typescript: '>=5.1.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@6.19.3': + resolution: {integrity: sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==} + + '@prisma/debug@6.19.3': + resolution: {integrity: sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==} + + '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': + resolution: {integrity: sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==} + + '@prisma/engines@6.19.3': + resolution: {integrity: sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==} + + '@prisma/fetch-engine@6.19.3': + resolution: {integrity: sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==} + + '@prisma/get-platform@6.19.3': + resolution: {integrity: sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -10083,6 +10334,14 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + c12@3.1.0: + resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} + peerDependencies: + magicast: ^0.3.5 + peerDependenciesMeta: + magicast: + optional: true + c12@3.3.3: resolution: {integrity: sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==} peerDependencies: @@ -10643,6 +10902,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -10783,6 +11046,102 @@ packages: resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==} engines: {node: '>=10'} + drizzle-kit@0.31.10: + resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} + hasBin: true + + drizzle-orm@0.45.2: + resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + dts-resolver@2.1.3: resolution: {integrity: sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw==} engines: {node: '>=20.19.0'} @@ -10813,6 +11172,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + effect@3.21.0: + resolution: {integrity: sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==} + ejs@5.0.1: resolution: {integrity: sha512-COqBPFMxuPTPspXl2DkVYaDS3HtrD1GpzOGkNTJ1IYkifq/r9h8SVEFrjA3D9/VJGOEoMQcrlhpntcSUrM8k6A==} engines: {node: '>=0.12.18'} @@ -10938,6 +11300,11 @@ packages: esbuild: '>=0.12' solid-js: '>= 1.0' + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.20.2: resolution: {integrity: sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==} engines: {node: '>=12'} @@ -11219,6 +11586,10 @@ packages: engines: {node: '>= 10.17.0'} hasBin: true + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -13604,6 +13975,9 @@ packages: pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + perfect-debounce@2.0.0: resolution: {integrity: sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==} @@ -13753,6 +14127,16 @@ packages: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + prisma@6.19.3: + resolution: {integrity: sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==} + engines: {node: '>=18.18'} + hasBin: true + peerDependencies: + typescript: '>=5.1.0' + peerDependenciesMeta: + typescript: + optional: true + proc-log@4.2.0: resolution: {integrity: sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -13838,6 +14222,9 @@ packages: engines: {node: '>=18'} hasBin: true + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + qs@6.14.0: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} @@ -17927,6 +18314,8 @@ snapshots: '@deno/shim-deno-test': 0.5.0 which: 4.0.0 + '@drizzle-team/brocli@0.10.2': {} + '@elevenlabs/client@1.3.1(@types/dom-mediacapture-record@1.0.22)': dependencies: '@elevenlabs/types': 0.9.1 @@ -17983,6 +18372,16 @@ snapshots: dependencies: tslib: 2.8.1 + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.14.0 + '@esbuild/aix-ppc64@0.20.2': optional: true @@ -17998,6 +18397,9 @@ snapshots: '@esbuild/aix-ppc64@0.28.1': optional: true + '@esbuild/android-arm64@0.18.20': + optional: true + '@esbuild/android-arm64@0.20.2': optional: true @@ -18013,6 +18415,9 @@ snapshots: '@esbuild/android-arm64@0.28.1': optional: true + '@esbuild/android-arm@0.18.20': + optional: true + '@esbuild/android-arm@0.20.2': optional: true @@ -18028,6 +18433,9 @@ snapshots: '@esbuild/android-arm@0.28.1': optional: true + '@esbuild/android-x64@0.18.20': + optional: true + '@esbuild/android-x64@0.20.2': optional: true @@ -18043,6 +18451,9 @@ snapshots: '@esbuild/android-x64@0.28.1': optional: true + '@esbuild/darwin-arm64@0.18.20': + optional: true + '@esbuild/darwin-arm64@0.20.2': optional: true @@ -18058,6 +18469,9 @@ snapshots: '@esbuild/darwin-arm64@0.28.1': optional: true + '@esbuild/darwin-x64@0.18.20': + optional: true + '@esbuild/darwin-x64@0.20.2': optional: true @@ -18073,6 +18487,9 @@ snapshots: '@esbuild/darwin-x64@0.28.1': optional: true + '@esbuild/freebsd-arm64@0.18.20': + optional: true + '@esbuild/freebsd-arm64@0.20.2': optional: true @@ -18088,6 +18505,9 @@ snapshots: '@esbuild/freebsd-arm64@0.28.1': optional: true + '@esbuild/freebsd-x64@0.18.20': + optional: true + '@esbuild/freebsd-x64@0.20.2': optional: true @@ -18103,6 +18523,9 @@ snapshots: '@esbuild/freebsd-x64@0.28.1': optional: true + '@esbuild/linux-arm64@0.18.20': + optional: true + '@esbuild/linux-arm64@0.20.2': optional: true @@ -18118,6 +18541,9 @@ snapshots: '@esbuild/linux-arm64@0.28.1': optional: true + '@esbuild/linux-arm@0.18.20': + optional: true + '@esbuild/linux-arm@0.20.2': optional: true @@ -18133,6 +18559,9 @@ snapshots: '@esbuild/linux-arm@0.28.1': optional: true + '@esbuild/linux-ia32@0.18.20': + optional: true + '@esbuild/linux-ia32@0.20.2': optional: true @@ -18148,6 +18577,9 @@ snapshots: '@esbuild/linux-ia32@0.28.1': optional: true + '@esbuild/linux-loong64@0.18.20': + optional: true + '@esbuild/linux-loong64@0.20.2': optional: true @@ -18163,6 +18595,9 @@ snapshots: '@esbuild/linux-loong64@0.28.1': optional: true + '@esbuild/linux-mips64el@0.18.20': + optional: true + '@esbuild/linux-mips64el@0.20.2': optional: true @@ -18178,6 +18613,9 @@ snapshots: '@esbuild/linux-mips64el@0.28.1': optional: true + '@esbuild/linux-ppc64@0.18.20': + optional: true + '@esbuild/linux-ppc64@0.20.2': optional: true @@ -18193,6 +18631,9 @@ snapshots: '@esbuild/linux-ppc64@0.28.1': optional: true + '@esbuild/linux-riscv64@0.18.20': + optional: true + '@esbuild/linux-riscv64@0.20.2': optional: true @@ -18208,6 +18649,9 @@ snapshots: '@esbuild/linux-riscv64@0.28.1': optional: true + '@esbuild/linux-s390x@0.18.20': + optional: true + '@esbuild/linux-s390x@0.20.2': optional: true @@ -18223,6 +18667,9 @@ snapshots: '@esbuild/linux-s390x@0.28.1': optional: true + '@esbuild/linux-x64@0.18.20': + optional: true + '@esbuild/linux-x64@0.20.2': optional: true @@ -18250,6 +18697,9 @@ snapshots: '@esbuild/netbsd-arm64@0.28.1': optional: true + '@esbuild/netbsd-x64@0.18.20': + optional: true + '@esbuild/netbsd-x64@0.20.2': optional: true @@ -18277,6 +18727,9 @@ snapshots: '@esbuild/openbsd-arm64@0.28.1': optional: true + '@esbuild/openbsd-x64@0.18.20': + optional: true + '@esbuild/openbsd-x64@0.20.2': optional: true @@ -18304,6 +18757,9 @@ snapshots: '@esbuild/openharmony-arm64@0.28.1': optional: true + '@esbuild/sunos-x64@0.18.20': + optional: true + '@esbuild/sunos-x64@0.20.2': optional: true @@ -18319,6 +18775,9 @@ snapshots: '@esbuild/sunos-x64@0.28.1': optional: true + '@esbuild/win32-arm64@0.18.20': + optional: true + '@esbuild/win32-arm64@0.20.2': optional: true @@ -18334,6 +18793,9 @@ snapshots: '@esbuild/win32-arm64@0.28.1': optional: true + '@esbuild/win32-ia32@0.18.20': + optional: true + '@esbuild/win32-ia32@0.20.2': optional: true @@ -18349,6 +18811,9 @@ snapshots: '@esbuild/win32-ia32@0.28.1': optional: true + '@esbuild/win32-x64@0.18.20': + optional: true + '@esbuild/win32-x64@0.20.2': optional: true @@ -20306,6 +20771,47 @@ snapshots: '@poppinss/exception@1.2.3': {} + '@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)': + optionalDependencies: + prisma: 6.19.3(magicast@0.5.2)(typescript@5.9.3) + typescript: 5.9.3 + optional: true + + '@prisma/client@6.19.3(prisma@6.19.3(typescript@7.0.2))(typescript@7.0.2)': + optionalDependencies: + prisma: 6.19.3(typescript@7.0.2) + typescript: 7.0.2 + + '@prisma/config@6.19.3(magicast@0.5.2)': + dependencies: + c12: 3.1.0(magicast@0.5.2) + deepmerge-ts: 7.1.5 + effect: 3.21.0 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + '@prisma/debug@6.19.3': {} + + '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': {} + + '@prisma/engines@6.19.3': + dependencies: + '@prisma/debug': 6.19.3 + '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 + '@prisma/fetch-engine': 6.19.3 + '@prisma/get-platform': 6.19.3 + + '@prisma/fetch-engine@6.19.3': + dependencies: + '@prisma/debug': 6.19.3 + '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 + '@prisma/get-platform': 6.19.3 + + '@prisma/get-platform@6.19.3': + dependencies: + '@prisma/debug': 6.19.3 + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -22142,9 +22648,45 @@ snapshots: '@tanstack/history@1.154.14': {} - '@tanstack/nitro-v2-vite-plugin@1.155.0(aws4fetch@1.0.20)(rolldown@1.1.5)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))': + '@tanstack/nitro-v2-vite-plugin@1.155.0(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3)))(rolldown@1.1.5)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))': dependencies: - nitropack: 2.13.1(aws4fetch@1.0.20)(rolldown@1.1.5) + nitropack: 2.13.1(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3)))(rolldown@1.1.5) + pathe: 2.0.3 + vite: 8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bare-abort-controller + - better-sqlite3 + - drizzle-orm + - encoding + - idb-keyval + - mysql2 + - react-native-b4a + - rolldown + - sqlite3 + - supports-color + - uploadthing + - xml2js + + '@tanstack/nitro-v2-vite-plugin@1.155.0(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(rolldown@1.1.5)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))': + dependencies: + nitropack: 2.13.1(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(rolldown@1.1.5) pathe: 2.0.3 vite: 8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: @@ -22279,9 +22821,9 @@ snapshots: tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/react-start-plugin@1.131.50(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@vitejs/plugin-react@4.7.0(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(aws4fetch@1.0.20)(rolldown@1.1.5)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))': + '@tanstack/react-start-plugin@1.131.50(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@vitejs/plugin-react@4.7.0(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(rolldown@1.1.5)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))': dependencies: - '@tanstack/start-plugin-core': 1.131.50(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(aws4fetch@1.0.20)(rolldown@1.1.5)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) + '@tanstack/start-plugin-core': 1.131.50(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(rolldown@1.1.5)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) '@vitejs/plugin-react': 4.7.0(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) pathe: 2.0.3 vite: 8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) @@ -22321,11 +22863,11 @@ snapshots: - webpack - xml2js - '@tanstack/react-start-router-manifest@1.120.19(@types/node@24.10.3)(aws4fetch@1.0.20)(db0@0.3.4)(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)': + '@tanstack/react-start-router-manifest@1.120.19(@types/node@24.10.3)(aws4fetch@1.0.20)(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)': dependencies: '@tanstack/router-core': 1.157.16 tiny-invariant: 1.3.3 - vinxi: 0.5.3(@types/node@24.10.3)(aws4fetch@1.0.20)(db0@0.3.4)(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) + vinxi: 0.5.3(@types/node@24.10.3)(aws4fetch@1.0.20)(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -22806,11 +23348,11 @@ snapshots: '@tanstack/store': 0.8.0 solid-js: 1.9.10 - '@tanstack/start-api-routes@1.120.19(@types/node@24.10.3)(aws4fetch@1.0.20)(crossws@0.4.6(srvx@0.11.17))(db0@0.3.4)(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)': + '@tanstack/start-api-routes@1.120.19(@types/node@24.10.3)(aws4fetch@1.0.20)(crossws@0.4.6(srvx@0.11.17))(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)': dependencies: '@tanstack/router-core': 1.157.16 '@tanstack/start-server-core': 1.141.1(crossws@0.4.6(srvx@0.11.17)) - vinxi: 0.5.3(@types/node@24.10.3)(aws4fetch@1.0.20)(db0@0.3.4)(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) + vinxi: 0.5.3(@types/node@24.10.3)(aws4fetch@1.0.20)(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -22882,21 +23424,21 @@ snapshots: tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/start-config@1.120.20(@types/node@24.10.3)(aws4fetch@1.0.20)(crossws@0.4.6(srvx@0.11.17))(db0@0.3.4)(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0)': + '@tanstack/start-config@1.120.20(@types/node@24.10.3)(aws4fetch@1.0.20)(crossws@0.4.6(srvx@0.11.17))(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0)': dependencies: '@tanstack/react-router': 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/react-start-plugin': 1.131.50(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@vitejs/plugin-react@4.7.0(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(aws4fetch@1.0.20)(rolldown@1.1.5)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) + '@tanstack/react-start-plugin': 1.131.50(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@vitejs/plugin-react@4.7.0(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(rolldown@1.1.5)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) '@tanstack/router-generator': 1.141.1 '@tanstack/router-plugin': 1.159.5(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) '@tanstack/server-functions-plugin': 1.141.0(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) '@tanstack/start-server-functions-handler': 1.120.19(crossws@0.4.6(srvx@0.11.17)) '@vitejs/plugin-react': 4.7.0(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) import-meta-resolve: 4.2.0 - nitropack: 2.13.1(aws4fetch@1.0.20)(rolldown@1.1.5) + nitropack: 2.13.1(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(rolldown@1.1.5) ofetch: 1.5.1 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - vinxi: 0.5.3(@types/node@24.10.3)(aws4fetch@1.0.20)(db0@0.3.4)(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) + vinxi: 0.5.3(@types/node@24.10.3)(aws4fetch@1.0.20)(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) vite: 8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) zod: 3.25.76 transitivePeerDependencies: @@ -22950,7 +23492,7 @@ snapshots: '@tanstack/start-fn-stubs@1.154.7': {} - '@tanstack/start-plugin-core@1.131.50(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(aws4fetch@1.0.20)(rolldown@1.1.5)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))': + '@tanstack/start-plugin-core@1.131.50(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(rolldown@1.1.5)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@babel/code-frame': 7.26.2 '@babel/core': 7.29.0 @@ -22966,7 +23508,7 @@ snapshots: babel-dead-code-elimination: 1.0.10 cheerio: 1.1.2 h3: 1.13.0 - nitropack: 2.13.1(aws4fetch@1.0.20)(rolldown@1.1.5) + nitropack: 2.13.1(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(rolldown@1.1.5) pathe: 2.0.3 ufo: 1.6.3 vite: 8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) @@ -23190,13 +23732,13 @@ snapshots: dependencies: '@tanstack/router-core': 1.159.4 - '@tanstack/start@1.120.20(@types/node@24.10.3)(aws4fetch@1.0.20)(crossws@0.4.6(srvx@0.11.17))(db0@0.3.4)(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0)': + '@tanstack/start@1.120.20(@types/node@24.10.3)(aws4fetch@1.0.20)(crossws@0.4.6(srvx@0.11.17))(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0)': dependencies: '@tanstack/react-start-client': 1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/react-start-router-manifest': 1.120.19(@types/node@24.10.3)(aws4fetch@1.0.20)(db0@0.3.4)(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) + '@tanstack/react-start-router-manifest': 1.120.19(@types/node@24.10.3)(aws4fetch@1.0.20)(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) '@tanstack/react-start-server': 1.141.1(crossws@0.4.6(srvx@0.11.17))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/start-api-routes': 1.120.19(@types/node@24.10.3)(aws4fetch@1.0.20)(crossws@0.4.6(srvx@0.11.17))(db0@0.3.4)(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) - '@tanstack/start-config': 1.120.20(@types/node@24.10.3)(aws4fetch@1.0.20)(crossws@0.4.6(srvx@0.11.17))(db0@0.3.4)(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0) + '@tanstack/start-api-routes': 1.120.19(@types/node@24.10.3)(aws4fetch@1.0.20)(crossws@0.4.6(srvx@0.11.17))(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) + '@tanstack/start-config': 1.120.20(@types/node@24.10.3)(aws4fetch@1.0.20)(crossws@0.4.6(srvx@0.11.17))(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0) '@tanstack/start-server-functions-client': 1.131.50(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) '@tanstack/start-server-functions-handler': 1.120.19(crossws@0.4.6(srvx@0.11.17)) '@tanstack/start-server-functions-server': 1.131.2(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) @@ -24707,6 +25249,23 @@ snapshots: bytes@3.1.2: {} + c12@3.1.0(magicast@0.5.2): + dependencies: + chokidar: 4.0.3 + confbox: 0.2.2 + defu: 6.1.4 + dotenv: 16.6.1 + exsolve: 1.0.8 + giget: 2.0.0 + jiti: 2.7.0 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 1.0.0 + pkg-types: 2.3.0 + rc9: 2.1.2 + optionalDependencies: + magicast: 0.5.2 + c12@3.3.3(magicast@0.5.2): dependencies: chokidar: 5.0.0 @@ -25194,7 +25753,13 @@ snapshots: dayjs@1.11.19: {} - db0@0.3.4: {} + db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))): + optionalDependencies: + drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3)) + + db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))): + optionalDependencies: + drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)) de-indent@1.0.2: {} @@ -25245,6 +25810,8 @@ snapshots: deep-is@0.1.4: {} + deepmerge-ts@7.1.5: {} + deepmerge@4.3.1: {} defaults@1.0.4: @@ -25373,6 +25940,36 @@ snapshots: dotenv@8.6.0: {} + drizzle-kit@0.31.10: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + tsx: 4.21.0 + + drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3)): + optionalDependencies: + '@cloudflare/workers-types': 4.20260317.1 + '@opentelemetry/api': 1.9.1 + '@prisma/client': 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) + prisma: 6.19.3(magicast@0.5.2)(typescript@5.9.3) + optional: true + + drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)): + optionalDependencies: + '@cloudflare/workers-types': 4.20260317.1 + '@opentelemetry/api': 1.9.1 + '@prisma/client': 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) + prisma: 6.19.3(magicast@0.5.2)(typescript@5.9.3) + optional: true + + drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@7.0.2))(typescript@7.0.2))(prisma@6.19.3(typescript@7.0.2)): + optionalDependencies: + '@cloudflare/workers-types': 4.20260317.1 + '@opentelemetry/api': 1.9.1 + '@prisma/client': 6.19.3(prisma@6.19.3(typescript@7.0.2))(typescript@7.0.2) + prisma: 6.19.3(typescript@7.0.2) + dts-resolver@2.1.3(oxc-resolver@11.21.3): optionalDependencies: oxc-resolver: 11.21.3 @@ -25400,6 +25997,11 @@ snapshots: ee-first@1.1.1: {} + effect@3.21.0: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + ejs@5.0.1: {} electron-to-chromium@1.5.267: {} @@ -25513,6 +26115,31 @@ snapshots: transitivePeerDependencies: - supports-color + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + esbuild@0.20.2: optionalDependencies: '@esbuild/aix-ppc64': 0.20.2 @@ -26060,6 +26687,10 @@ snapshots: transitivePeerDependencies: - supports-color + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + fast-deep-equal@3.1.3: {} fast-equals@5.4.0: {} @@ -28399,11 +29030,11 @@ snapshots: rollup: 4.60.1 tailwindcss: 4.1.18 - nitro@3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0): + nitro@3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@7.3.3(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0): dependencies: consola: 3.4.2 crossws: 0.4.6(srvx@0.11.17) - db0: 0.3.4 + db0: 0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))) env-runner: 0.1.14(miniflare@4.20260617.1)(wrangler@4.103.0) h3: 2.0.1-rc.22(crossws@0.4.6(srvx@0.11.17)) hookable: 6.1.1 @@ -28414,7 +29045,7 @@ snapshots: rolldown: 1.1.5 srvx: 0.11.17 unenv: 2.0.0-rc.24 - unstorage: 2.0.0-alpha.7(aws4fetch@1.0.20)(chokidar@5.0.0)(db0@0.3.4)(ofetch@2.0.0-alpha.3) + unstorage: 2.0.0-alpha.7(aws4fetch@1.0.20)(chokidar@5.0.0)(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(ofetch@2.0.0-alpha.3) optionalDependencies: dotenv: 17.2.3 giget: 2.0.0 @@ -28453,11 +29084,11 @@ snapshots: - uploadthing - wrangler - nitro@3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0): + nitro@3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(giget@2.0.0)(jiti@2.7.0)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0))(wrangler@4.103.0): dependencies: consola: 3.4.2 crossws: 0.4.6(srvx@0.11.17) - db0: 0.3.4 + db0: 0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))) env-runner: 0.1.14(miniflare@4.20260617.1)(wrangler@4.103.0) h3: 2.0.1-rc.22(crossws@0.4.6(srvx@0.11.17)) hookable: 6.1.1 @@ -28468,7 +29099,7 @@ snapshots: rolldown: 1.1.5 srvx: 0.11.17 unenv: 2.0.0-rc.24 - unstorage: 2.0.0-alpha.7(aws4fetch@1.0.20)(chokidar@5.0.0)(db0@0.3.4)(ofetch@2.0.0-alpha.3) + unstorage: 2.0.0-alpha.7(aws4fetch@1.0.20)(chokidar@5.0.0)(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(ofetch@2.0.0-alpha.3) optionalDependencies: dotenv: 17.2.3 giget: 2.0.0 @@ -28507,7 +29138,7 @@ snapshots: - uploadthing - wrangler - nitropack@2.13.1(aws4fetch@1.0.20)(rolldown@1.1.5): + nitropack@2.13.1(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3)))(rolldown@1.1.5): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 '@rollup/plugin-alias': 6.0.0(rollup@4.60.1) @@ -28528,7 +29159,7 @@ snapshots: cookie-es: 2.0.1 croner: 9.1.0 crossws: 0.3.5 - db0: 0.3.4 + db0: 0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))) defu: 6.1.4 destr: 2.0.5 dot-prop: 10.1.0 @@ -28574,7 +29205,109 @@ snapshots: unenv: 2.0.0-rc.24 unimport: 5.6.0 unplugin-utils: 0.3.1 - unstorage: 1.17.4(aws4fetch@1.0.20)(db0@0.3.4)(ioredis@5.9.2) + unstorage: 1.17.4(aws4fetch@1.0.20)(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))))(ioredis@5.9.2) + untyped: 2.0.0 + unwasm: 0.5.3 + youch: 4.1.0-beta.13 + youch-core: 0.3.3 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bare-abort-controller + - better-sqlite3 + - drizzle-orm + - encoding + - idb-keyval + - mysql2 + - react-native-b4a + - rolldown + - sqlite3 + - supports-color + - uploadthing + + nitropack@2.13.1(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(rolldown@1.1.5): + dependencies: + '@cloudflare/kv-asset-handler': 0.4.2 + '@rollup/plugin-alias': 6.0.0(rollup@4.60.1) + '@rollup/plugin-commonjs': 29.0.0(rollup@4.60.1) + '@rollup/plugin-inject': 5.0.5(rollup@4.60.1) + '@rollup/plugin-json': 6.1.0(rollup@4.60.1) + '@rollup/plugin-node-resolve': 16.0.3(rollup@4.60.1) + '@rollup/plugin-replace': 6.0.3(rollup@4.60.1) + '@rollup/plugin-terser': 0.4.4(rollup@4.60.1) + '@vercel/nft': 1.3.0(rollup@4.60.1) + archiver: 7.0.1 + c12: 3.3.3(magicast@0.5.2) + chokidar: 5.0.0 + citty: 0.1.6 + compatx: 0.2.0 + confbox: 0.2.2 + consola: 3.4.2 + cookie-es: 2.0.1 + croner: 9.1.0 + crossws: 0.3.5 + db0: 0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))) + defu: 6.1.4 + destr: 2.0.5 + dot-prop: 10.1.0 + esbuild: 0.27.7 + escape-string-regexp: 5.0.0 + etag: 1.8.1 + exsolve: 1.0.8 + globby: 16.1.0 + gzip-size: 7.0.0 + h3: 1.15.5 + hookable: 5.5.3 + httpxy: 0.1.7 + ioredis: 5.9.2 + jiti: 2.7.0 + klona: 2.0.6 + knitwork: 1.3.0 + listhen: 1.9.0 + magic-string: 0.30.21 + magicast: 0.5.2 + mime: 4.1.0 + mlly: 1.8.0 + node-fetch-native: 1.6.7 + node-mock-http: 1.0.4 + ofetch: 1.5.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.0.0 + pkg-types: 2.3.0 + pretty-bytes: 7.1.0 + radix3: 1.1.2 + rollup: 4.60.1 + rollup-plugin-visualizer: 6.0.5(rolldown@1.1.5)(rollup@4.60.1) + scule: 1.3.0 + semver: 7.8.4 + serve-placeholder: 2.0.2 + serve-static: 2.2.1 + source-map: 0.7.6 + std-env: 3.10.0 + ufo: 1.6.3 + ultrahtml: 1.6.0 + uncrypto: 0.1.3 + unctx: 2.5.0 + unenv: 2.0.0-rc.24 + unimport: 5.6.0 + unplugin-utils: 0.3.1 + unstorage: 1.17.4(aws4fetch@1.0.20)(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(ioredis@5.9.2) untyped: 2.0.0 unwasm: 0.5.3 youch: 4.1.0-beta.13 @@ -29240,6 +29973,8 @@ snapshots: pend@1.2.0: {} + perfect-debounce@1.0.0: {} + perfect-debounce@2.0.0: {} picocolors@1.1.1: {} @@ -29353,6 +30088,25 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3): + dependencies: + '@prisma/config': 6.19.3(magicast@0.5.2) + '@prisma/engines': 6.19.3 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - magicast + optional: true + + prisma@6.19.3(typescript@7.0.2): + dependencies: + '@prisma/config': 6.19.3(magicast@0.5.2) + '@prisma/engines': 6.19.3 + optionalDependencies: + typescript: 7.0.2 + transitivePeerDependencies: + - magicast + proc-log@4.2.0: {} process-nextick-args@2.0.1: {} @@ -29486,6 +30240,8 @@ snapshots: - typescript - utf-8-validate + pure-rand@6.1.0: {} + qs@6.14.0: dependencies: side-channel: 1.1.0 @@ -31324,7 +32080,22 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' - unstorage@1.17.4(aws4fetch@1.0.20)(db0@0.3.4)(ioredis@5.9.2): + unstorage@1.17.4(aws4fetch@1.0.20)(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))))(ioredis@5.9.2): + dependencies: + anymatch: 3.1.3 + chokidar: 5.0.0 + destr: 2.0.5 + h3: 1.15.5 + lru-cache: 11.2.4 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + ufo: 1.6.3 + optionalDependencies: + aws4fetch: 1.0.20 + db0: 0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(magicast@0.5.2)(typescript@5.9.3))) + ioredis: 5.9.2 + + unstorage@1.17.4(aws4fetch@1.0.20)(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(ioredis@5.9.2): dependencies: anymatch: 3.1.3 chokidar: 5.0.0 @@ -31336,14 +32107,14 @@ snapshots: ufo: 1.6.3 optionalDependencies: aws4fetch: 1.0.20 - db0: 0.3.4 + db0: 0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))) ioredis: 5.9.2 - unstorage@2.0.0-alpha.7(aws4fetch@1.0.20)(chokidar@5.0.0)(db0@0.3.4)(ofetch@2.0.0-alpha.3): + unstorage@2.0.0-alpha.7(aws4fetch@1.0.20)(chokidar@5.0.0)(db0@0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(ofetch@2.0.0-alpha.3): optionalDependencies: aws4fetch: 1.0.20 chokidar: 5.0.0 - db0: 0.3.4 + db0: 0.3.4(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))) ofetch: 2.0.0-alpha.3 untun@0.1.3: @@ -31444,7 +32215,7 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vinxi@0.5.3(@types/node@24.10.3)(aws4fetch@1.0.20)(db0@0.3.4)(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0): + vinxi@0.5.3(@types/node@24.10.3)(aws4fetch@1.0.20)(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(ioredis@5.9.2)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(rolldown@1.1.5)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0): dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.0) @@ -31466,7 +32237,7 @@ snapshots: hookable: 5.5.3 http-proxy: 1.18.1 micromatch: 4.0.8 - nitropack: 2.13.1(aws4fetch@1.0.20)(rolldown@1.1.5) + nitropack: 2.13.1(aws4fetch@1.0.20)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3)))(rolldown@1.1.5) node-fetch-native: 1.6.7 path-to-regexp: 6.3.0 pathe: 1.1.2 @@ -31477,7 +32248,7 @@ snapshots: ufo: 1.6.1 unctx: 2.4.1 unenv: 1.10.0 - unstorage: 1.17.4(aws4fetch@1.0.20)(db0@0.3.4)(ioredis@5.9.2) + unstorage: 1.17.4(aws4fetch@1.0.20)(db0@0.3.4(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(typescript@5.9.3))))(ioredis@5.9.2) vite: 6.4.2(@types/node@24.10.3)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0) zod: 3.25.76 transitivePeerDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d0616db5d..6a2b290b4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -95,3 +95,9 @@ allowBuilds: # JS fallback, so their native builds are not required. cpu-features: false ssh2: false + # @tanstack/ai-persistence-prisma runs `prisma generate` (nx `db:generate`) + # as a build/typecheck prerequisite, so the Prisma engine + client build + # scripts must run. + '@prisma/client': true + '@prisma/engines': true + prisma: true From 29382a90f20d6ecc8e461efdd795e8b3b4dea1a1 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 22 Jul 2026 18:06:35 +0200 Subject: [PATCH 02/26] feat(ai-client): browser-refresh durability via the persistence option The persistence adapter now stores one combined { messages, resume? } record per chat id, so a full page reload restores the transcript, rehydrates pending interrupts, and rejoins an in-flight run through joinRun when the connection is durability-backed. Legacy bare-array records are still read. Adds localStoragePersistence / sessionStoragePersistence / indexedDBPersistence (+ StorageUnavailableError and the ChatPersistedState / ChatStorageAdapter types). Durability rides the existing option, so every framework integration gets it with no framework-specific code. Claude-Session: https://claude.ai/code/session_01RqjWdHxvmMrhjbd8dYvENp --- .../client-browser-refresh-durability.md | 9 + packages/ai-client/src/chat-client.ts | 114 +++++++- packages/ai-client/src/client-persistor.ts | 111 +++++-- packages/ai-client/src/connection-adapters.ts | 25 ++ packages/ai-client/src/index.ts | 16 + packages/ai-client/src/storage-adapters.ts | 275 ++++++++++++++++++ packages/ai-client/src/types.ts | 55 +++- .../ai-client/tests/resume-snapshot.test.ts | 183 ++++++++++++ 8 files changed, 747 insertions(+), 41 deletions(-) create mode 100644 .changeset/client-browser-refresh-durability.md create mode 100644 packages/ai-client/src/storage-adapters.ts create mode 100644 packages/ai-client/tests/resume-snapshot.test.ts diff --git a/.changeset/client-browser-refresh-durability.md b/.changeset/client-browser-refresh-durability.md new file mode 100644 index 000000000..f7499f4e5 --- /dev/null +++ b/.changeset/client-browser-refresh-durability.md @@ -0,0 +1,9 @@ +--- +'@tanstack/ai-client': 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. + +New web storage adapters are exported for this: `localStoragePersistence`, `sessionStoragePersistence`, and `indexedDBPersistence` (plus `StorageUnavailableError` and the `ChatPersistedState` / `ChatStorageAdapter` 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/packages/ai-client/src/chat-client.ts b/packages/ai-client/src/chat-client.ts index 920f6cd5d..382e125ca 100644 --- a/packages/ai-client/src/chat-client.ts +++ b/packages/ai-client/src/chat-client.ts @@ -248,9 +248,11 @@ export class ChatClient< private connection: SubscribeConnectionAdapter private readonly uniqueId: string private readonly threadId: string - // Message persistence (optional). Clear-during-stream suppression is always - // on via ClearedStreamTracker so `clear()` works without a storage adapter. - // Durable resume-snapshot storage is not wired here (feat/persistence). + // Durable chat persistence (optional): messages + resume snapshot as one + // combined record, so a full page reload restores the transcript, rehydrates + // pending interrupts, and rejoins an in-flight run. Clear-during-stream + // suppression is always on via ClearedStreamTracker so `clear()` works + // without a storage adapter. private readonly persistor?: ChatPersistor private readonly clearedStreamTracker = new ClearedStreamTracker() private currentRunId: string | null = null @@ -375,6 +377,7 @@ export class ChatClient< options.persistence, this.uniqueId, (messages) => this.processor.setMessages(messages), + (snapshot) => this.applyResumeSnapshot(snapshot), ) } // Both `body` (deprecated) and `forwardedProps` populate the AG-UI @@ -439,10 +442,28 @@ export class ChatClient< // Create StreamProcessor with event handlers. // Use conditional spreads so we don't pass `undefined` into // `StreamProcessorOptions` fields under `exactOptionalPropertyTypes`. - const persistedMessages = this.persistor?.readInitial() - const initialMessages = Array.isArray(persistedMessages) - ? persistedMessages + const persistedState = this.persistor?.readInitial() + const syncPersistedState = + persistedState instanceof Promise ? undefined : persistedState + const initialMessages = syncPersistedState + ? syncPersistedState.messages : options.initialMessages + // A durable snapshot read synchronously from storage wins over the + // in-memory `initialResumeSnapshot` fallback applied above. A snapshot with + // pending interrupts rehydrates the interrupt UI; a bare in-flight run is + // rejoined after the processor is ready (see `rejoinRunId` below). + let rejoinRunId: string | null = null + if (syncPersistedState?.resume) { + const snapshot = syncPersistedState.resume + const hasPendingInterrupts = + Array.isArray(snapshot.pendingInterrupts) && + snapshot.pendingInterrupts.length > 0 + if (hasPendingInterrupts) { + this.applyResumeSnapshot(snapshot) + } else if (snapshot.resumeState.runId) { + rejoinRunId = snapshot.resumeState.runId + } + } this.processor = new StreamProcessor({ ...(options.streamProcessor?.chunkStrategy @@ -649,7 +670,14 @@ export class ChatClient< }, }) - this.persistor?.hydrateAsync(persistedMessages) + this.persistor?.hydrateAsync(persistedState) + + // Full page reload with an in-flight run persisted: re-attach to it off the + // server's delivery-durability log so the stream finishes here. Best-effort + // and non-blocking; a finished/evicted run just replays or no-ops. + if (rejoinRunId && this.connection.joinRun) { + this.resumeInFlightRun(rejoinRunId) + } } private applyResumeSnapshot(snapshot: ChatResumeSnapshot): void { @@ -724,6 +752,16 @@ export class ChatClient< this.activeRunIds.add(chunkRunId) this.clearedStreamTracker.onRunStarted(chunkRunId) this.setSessionGenerating(true) + // Persist a live-run resume snapshot so a full page reload can rejoin this + // in-flight run via joinRun. Only meaningful when the connection is + // resumable; skipped otherwise. Interrupt/terminal handling overwrites or + // clears it in observeInterruptState. + if (this.persistor && this.connection.joinRun && !this.lastResume) { + this.persistResumeSnapshot({ + threadId: this.activeResumeThreadId ?? this.threadId, + runId: chunkRunId, + }) + } return } @@ -821,6 +859,9 @@ export class ChatClient< isActiveInterruptSubmissionTerminal ) { this.lastResume = null + // Run settled without an interrupt: drop the durable resume snapshot so a + // later reload does not try to rejoin a finished run. + this.persistor?.persistResumeSnapshot(null) this.interruptManager.reset() return } @@ -1017,6 +1058,10 @@ export class ChatClient< private notifyResumeStateChange(): void { const resumeState = this.getResumeState() + // Persist (or clear) the durable resume snapshot so a full page reload can + // rehydrate pending interrupts and rejoin the run. Folded into the same + // persistence adapter that stores messages (one record per chat). + this.persistResumeSnapshot(resumeState) this.callbacksRef.current.onResumeStateChange( resumeState, this.interruptManager.getInterrupts(), @@ -1026,6 +1071,26 @@ export class ChatClient< ) } + /** + * Build the durable resume snapshot from the current resume state + pending + * interrupt descriptors and hand it to the persistor (null clears it). + */ + private persistResumeSnapshot(resumeState: ChatResumeState | null): void { + if (!this.persistor) return + if (!resumeState) { + this.persistor.persistResumeSnapshot(null) + return + } + const descriptors = this.interruptManager.getDescriptors() + this.persistor.persistResumeSnapshot({ + schemaVersion: 2, + resumeState, + ...(descriptors.length > 0 + ? { pendingInterrupts: [...descriptors] } + : {}), + }) + } + private resetSessionGenerating(options?: { preserveClearedStreamTracking?: boolean }): void { @@ -1186,6 +1251,41 @@ export class ChatClient< } } + /** + * Re-attach to an in-flight run after a full page reload, replaying its + * stream from the server's delivery-durability log via `joinRun`. Chunks flow + * through the normal processing path, so the restored (possibly partial) + * assistant message is updated in place by message id rather than duplicated. + * Best-effort: a finished, evicted, or unknown run replays to completion or + * fails quietly, leaving the restored transcript intact. + */ + private resumeInFlightRun(runId: string): void { + const joinRun = this.connection.joinRun + if (!joinRun) return + const controller = new AbortController() + this.abortController = controller + this.currentRunId = runId + this.setIsLoading(true) + this.setStatus('streaming') + void (async () => { + try { + for await (const chunk of joinRun(runId, controller.signal)) { + if (controller.signal.aborted) break + await this.processIncomingChunk(chunk) + } + } catch { + // Best-effort re-attach: the durable log may be gone or the run + // unknown. Keep the restored transcript rather than surfacing an error. + } finally { + if (this.abortController === controller) { + this.abortController = null + this.setIsLoading(false) + if (this.status === 'streaming') this.setStatus('ready') + } + } + })() + } + private async processIncomingChunk(chunk: StreamChunk): Promise { if ( chunk.type === 'RUN_ERROR' && diff --git a/packages/ai-client/src/client-persistor.ts b/packages/ai-client/src/client-persistor.ts index 517aaf0a7..e565b33c1 100644 --- a/packages/ai-client/src/client-persistor.ts +++ b/packages/ai-client/src/client-persistor.ts @@ -1,6 +1,20 @@ import { getChunkRunId } from './connection-adapters' import type { StreamChunk } from '@tanstack/ai/client' -import type { ChatClientPersistence, UIMessage } from './types' +import type { + ChatClientPersistence, + ChatPersistedState, + ChatResumeSnapshot, + UIMessage, +} from './types' + +/** Normalize a raw `getItem` result (legacy bare array or combined record). */ +function normalizePersistedState( + raw: ChatPersistedState | Array | null | undefined, +): ChatPersistedState | undefined { + if (Array.isArray(raw)) return { messages: raw } + if (raw && Array.isArray(raw.messages)) return raw + return undefined +} // `StreamChunk` is a discriminated union; `toolCallId` / `messageId` / // `parentMessageId` exist on only some members. Narrow with `in` (matching @@ -51,6 +65,10 @@ export class ChatPersistor { // Bumped on every message change; lets an in-flight async hydration detect // that the message list moved on and avoid clobbering it. private messagesGeneration = 0 + // Latest messages + resume snapshot, written together as one combined record + // so a full page reload restores both from a single adapter key. + private lastMessages: Array = [] + private lastResume: ChatResumeSnapshot | null = null // --- clear-during-stream suppression state --- private readonly clearedMessageIds = new Set() @@ -63,51 +81,77 @@ export class ChatPersistor { private readonly adapter: ChatClientPersistence, private readonly id: string, private readonly applyMessages: (messages: Array) => void, + private readonly applyResume?: (snapshot: ChatResumeSnapshot) => void, ) {} + /** Persist the current messages + resume snapshot as one combined record. */ + private writeState(): void { + const generation = this.generation + const state: ChatPersistedState = { + messages: [...this.lastMessages], + ...(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.lastMessages = state.messages + this.lastResume = state.resume ?? null + this.applyMessages(state.messages) + if (state.resume && this.applyResume) { + this.applyResume(state.resume) } }) .catch(() => { @@ -116,28 +160,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..6430bc73d 100644 --- a/packages/ai-client/src/connection-adapters.ts +++ b/packages/ai-client/src/connection-adapters.ts @@ -693,6 +693,16 @@ export interface SubscribeConnectionAdapter { abortSignal?: AbortSignal, runContext?: RunAgentInputContext, ) => Promise + /** + * Re-attach to an existing run by id, replaying its stream from the start off + * the server's delivery-durability sink. Present only when the underlying + * connection is resumable (a `ResumableConnectConnectionAdapter`). Used to + * rejoin an in-flight run after a full page reload. + */ + joinRun?: ( + runId: string, + abortSignal?: AbortSignal, + ) => AsyncIterable } /** @@ -727,9 +737,13 @@ export function normalizeConnectionAdapter( } if (hasSubscribe && hasSend) { + const joinRun = ( + connection as SubscribeConnectionAdapter + ).joinRun?.bind(connection) return { subscribe: connection.subscribe.bind(connection), send: connection.send.bind(connection), + ...(joinRun ? { joinRun } : {}), } } @@ -858,6 +872,17 @@ export function normalizeConnectionAdapter( throw err } }, + // Expose joinRun only when the underlying connection is resumable. Returns + // the replay iterable directly; the client consumes it like a subscription. + ...('joinRun' in connection + ? { + joinRun: (runId: string, abortSignal?: AbortSignal) => + (connection as ResumableConnectConnectionAdapter).joinRun( + runId, + abortSignal, + ), + } + : {}), } } diff --git a/packages/ai-client/src/index.ts b/packages/ai-client/src/index.ts index b03ed1480..d0dcdc83b 100644 --- a/packages/ai-client/src/index.ts +++ b/packages/ai-client/src/index.ts @@ -28,6 +28,8 @@ export type { StructuredOutputPart, // Client configuration types ChatClientPersistence, + ChatPersistedState, + ChatStorageAdapter, ChatClientOptions, ChatPendingInterrupt, BoundInterruptBase, @@ -84,6 +86,20 @@ 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 { + JsonPrimitive, + JsonValue, + StorageCodec, + 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..755238328 --- /dev/null +++ b/packages/ai-client/src/storage-adapters.ts @@ -0,0 +1,275 @@ +import type { ChatStorageAdapter } from './types' + +export type JsonPrimitive = string | number | boolean | null +export type JsonValue = + | JsonPrimitive + | Array + | { [key: string]: JsonValue } + +export interface StorageCodec { + serialize: (value: TValue) => string + deserialize: (value: string) => TValue +} + +export interface WebStoragePersistenceOptions { + keyPrefix?: string + serialize?: (value: TValue) => string + deserialize?: (value: string) => TValue +} + +export interface IndexedDBPersistenceOptions { + databaseName?: string + objectStoreName?: string + keyPrefix?: string +} + +type NonJsonValue = + | bigint + | Date + | symbol + | undefined + | ((...args: Array) => unknown) + +type IsJsonSerializable = [TValue] extends [JsonPrimitive] + ? true + : [TValue] extends [NonJsonValue] + ? false + : TValue extends ReadonlyArray + ? IsJsonSerializable + : TValue extends object + ? false extends { + [TKey in keyof TValue]-?: IsJsonSerializable + }[keyof TValue] + ? false + : true + : false + +type WebStorageArguments = + IsJsonSerializable extends true + ? [options?: WebStoragePersistenceOptions] + : [options: WebStoragePersistenceOptions & StorageCodec] + +type StorageName = 'localStorage' | 'sessionStorage' | 'indexedDB' + +/** + * Thrown by a storage adapter when its backing store is absent — most commonly + * during server-side rendering, where `localStorage` / `sessionStorage` / + * `indexedDB` do not exist on `globalThis`. The adapters check availability + * lazily, **per operation**, so constructing an adapter never throws; the error + * surfaces from `getItem` / `setItem` / `removeItem` (rejected promise for + * IndexedDB). The chat persistence layer treats it as best-effort and routes it + * to `onError` / `console.warn` rather than breaking chat. + */ +export class StorageUnavailableError extends Error { + constructor(storageName: StorageName) { + super(`${storageName} is not available in this environment.`) + this.name = 'StorageUnavailableError' + } +} + +function stringifyJson(value: TValue): string { + const stringify: (input: unknown) => unknown = JSON.stringify + const serialized = stringify(value) + if (typeof serialized !== 'string') { + throw new TypeError('The value is not JSON serializable.') + } + return serialized +} + +function createWebStoragePersistence( + storageName: 'localStorage' | 'sessionStorage', + options: WebStoragePersistenceOptions, +): ChatStorageAdapter { + const keyPrefix = options.keyPrefix ?? 'tanstack-ai:' + const serialize = options.serialize ?? stringifyJson + const deserialize = options.deserialize ?? JSON.parse + const key = (id: string) => `${keyPrefix}${id}` + + const getStorage = (): Storage => { + const browserGlobals: { + localStorage?: Storage + sessionStorage?: Storage + } = globalThis + const storage = browserGlobals[storageName] + if (!storage) { + throw new StorageUnavailableError(storageName) + } + return storage + } + + return { + getItem(id) { + const item = getStorage().getItem(key(id)) + return item === null ? null : deserialize(item) + }, + setItem(id, value) { + getStorage().setItem(key(id), serialize(value)) + }, + removeItem(id) { + getStorage().removeItem(key(id)) + }, + } +} + +/** + * A `ChatStorageAdapter` backed by `window.localStorage` (persists across + * reloads and browser restarts). Keys are namespaced with `keyPrefix`, which + * defaults to `tanstack-ai:`. Every operation reads `localStorage` lazily and + * throws {@link StorageUnavailableError} when it is absent (e.g. SSR), so the + * adapter can be constructed safely on the server. + * + * The `serialize` / `deserialize` codec defaults to `JSON.stringify` / + * `JSON.parse`. When `TValue` is JSON-serializable the codec is optional; when + * it is not (it contains `Date`, `Map`, `bigint`, etc.), the type signature + * **requires** you to pass a `serialize`/`deserialize` pair. + */ +export function localStoragePersistence( + ...[options = {}]: WebStorageArguments +): 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: `tanstack-ai:` default `keyPrefix`, lazy per-operation + * {@link StorageUnavailableError} on SSR, and a JSON codec that becomes a + * required argument when `TValue` is not JSON-serializable. + */ +export function sessionStoragePersistence( + ...[options = {}]: WebStorageArguments +): 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. + */ +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..584e640c2 100644 --- a/packages/ai-client/src/types.ts +++ b/packages/ai-client/src/types.ts @@ -553,19 +553,62 @@ 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 } @@ -661,9 +704,11 @@ 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 adapter for durable chat state, keyed by chat id. It + * stores one combined {@link ChatPersistedState} record (messages + an + * optional resume snapshot), so a full page reload restores the transcript, + * rehydrates pending interrupts, and rejoins an in-flight run. Use + * `initialResumeSnapshot` instead for a host-supplied in-memory rehydrate. */ persistence?: ChatClientPersistence 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..542ef2a0a --- /dev/null +++ b/packages/ai-client/tests/resume-snapshot.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it, vi } from 'vitest' +import { ChatPersistor } from '../src/client-persistor' +import { normalizeConnectionAdapter } from '../src/connection-adapters' +import { ChatClient } from '../src/chat-client' +import { createUIMessage } from './test-utils' +import type { + ChatClientPersistence, + ChatPersistedState, + ChatResumeSnapshot, + UIMessage, +} from '../src/types' +import type { + ResumableConnectConnectionAdapter, + RunAgentInputContext, +} from '../src/connection-adapters' +import type { StreamChunk } from '@tanstack/ai/client' + +/** An in-memory store capturing the last combined record written. */ +function memoryAdapter(initial?: ChatPersistedState | Array): { + adapter: ChatClientPersistence + read: () => ChatPersistedState | Array | undefined +} { + let value = initial + return { + adapter: { + getItem: () => value, + setItem: (_id, state) => { + value = state + }, + removeItem: () => { + value = undefined + }, + }, + read: () => value, + } +} + +describe('ChatPersistor combined record', () => { + it('writes messages and resume snapshot as one record', () => { + const { adapter, read } = memoryAdapter() + const persistor = new ChatPersistor(adapter, 'chat-1', () => {}) + + persistor.notifyMessagesChanged([createUIMessage('m1', 'hello')]) + const snapshot: ChatResumeSnapshot = { + schemaVersion: 2, + resumeState: { threadId: 't1', runId: 'r1' }, + } + persistor.persistResumeSnapshot(snapshot) + + const stored = read() as ChatPersistedState + expect(stored.messages).toHaveLength(1) + expect(stored.resume?.resumeState.runId).toBe('r1') + }) + + it('clears the resume snapshot but keeps messages', () => { + const { adapter, read } = memoryAdapter() + const persistor = new ChatPersistor(adapter, 'chat-1', () => {}) + persistor.notifyMessagesChanged([createUIMessage('m1', 'hello')]) + persistor.persistResumeSnapshot({ + schemaVersion: 2, + resumeState: { threadId: 't1', runId: 'r1' }, + }) + persistor.persistResumeSnapshot(null) + + const stored = read() as ChatPersistedState + expect(stored.messages).toHaveLength(1) + expect(stored.resume).toBeUndefined() + }) + + it('normalizes a legacy bare-array record on read', () => { + const applied: Array> = [] + const { adapter } = memoryAdapter([createUIMessage('m1', 'legacy')]) + const persistor = new ChatPersistor(adapter, 'chat-1', (m) => + applied.push(m), + ) + const state = persistor.readInitial() as ChatPersistedState + expect(state.messages[0]?.id).toBe('m1') + expect(state.resume).toBeUndefined() + }) +}) + +describe('normalizeConnectionAdapter joinRun passthrough', () => { + it('exposes joinRun when the connection is resumable', () => { + const joinRun = vi.fn(async function* () { + // empty + }) + const resumable: ResumableConnectConnectionAdapter = { + connect: async function* () {}, + joinRun, + } + const normalized = normalizeConnectionAdapter(resumable) + expect(typeof normalized.joinRun).toBe('function') + }) + + it('omits joinRun for a plain connect adapter', () => { + const normalized = normalizeConnectionAdapter({ + connect: async function* () {}, + }) + expect(normalized.joinRun).toBeUndefined() + }) +}) + +function runChunks(runId: string, threadId: string): Array { + return [ + { type: 'RUN_STARTED', runId, threadId, timestamp: 1 } as StreamChunk, + { + type: 'TEXT_MESSAGE_START', + messageId: 'assistant-1', + role: 'assistant', + timestamp: 2, + } as StreamChunk, + { + type: 'TEXT_MESSAGE_CONTENT', + messageId: 'assistant-1', + delta: 'world', + content: 'world', + timestamp: 3, + } as StreamChunk, + { + type: 'TEXT_MESSAGE_END', + messageId: 'assistant-1', + timestamp: 4, + } as StreamChunk, + { + type: 'RUN_FINISHED', + runId, + threadId, + timestamp: 5, + finishReason: 'stop', + } as StreamChunk, + ] +} + +describe('ChatClient auto-rejoin after reload', () => { + it('rejoins a persisted in-flight run via joinRun', async () => { + // A store pre-seeded as if a previous session persisted a live run. + const { adapter } = memoryAdapter({ + messages: [createUIMessage('user-1', 'hi', 'user')], + resume: { schemaVersion: 2, resumeState: { threadId: 't1', runId: 'r1' } }, + }) + + const joinRun = vi.fn( + // eslint-disable-next-line require-yield + async function* (_runId: string) { + for (const chunk of runChunks('r1', 't1')) { + yield chunk + } + }, + ) + const connection: ResumableConnectConnectionAdapter = { + connect: async function* ( + _messages, + _data?: Record, + _signal?: AbortSignal, + _ctx?: RunAgentInputContext, + ) {}, + joinRun, + } + + let latest: Array = [] + const client = new ChatClient({ + id: 'chat-1', + threadId: 't1', + connection, + persistence: adapter, + onMessagesChange: (messages) => { + latest = messages + }, + }) + + // Rejoin is async; wait for the replayed run to finish. + await vi.waitFor(() => { + const assistant = latest.find((m) => m.role === 'assistant') + const text = assistant?.parts.find((p) => p.type === 'text') + expect(text && 'content' in text && text.content).toBe('world') + }) + + expect(joinRun).toHaveBeenCalledWith('r1', expect.anything()) + // The restored user message survives alongside the rejoined assistant reply. + expect(latest.some((m) => m.id === 'user-1')).toBe(true) + void client + }) +}) From 0ed55b8b0a5c27e35b98c8a2ff766619356a24c7 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 22 Jul 2026 18:07:16 +0200 Subject: [PATCH 03/26] example(ts-react-chat): persistent chat with SQLite + reload durability New /persistent-chat route: useChat with localStoragePersistence on the client and withChatPersistence(sqlitePersistence) on the server, so a full page reload restores the conversation on both ends. Adds a nav link and README section. Claude-Session: https://claude.ai/code/session_01RqjWdHxvmMrhjbd8dYvENp --- examples/ts-react-chat/.gitignore | 1 + examples/ts-react-chat/README.md | 12 ++ examples/ts-react-chat/package.json | 2 + .../ts-react-chat/src/components/Header.tsx | 14 ++ .../src/routes/api.persistent-chat.ts | 72 ++++++++++ .../src/routes/persistent-chat.tsx | 131 ++++++++++++++++++ 6 files changed, 232 insertions(+) create mode 100644 examples/ts-react-chat/src/routes/api.persistent-chat.ts create mode 100644 examples/ts-react-chat/src/routes/persistent-chat.tsx 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..740c1bb8f 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 `withChatPersistence` +(`@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/routes/api.persistent-chat.ts b/examples/ts-react-chat/src/routes/api.persistent-chat.ts new file mode 100644 index 000000000..802361ad4 --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.persistent-chat.ts @@ -0,0 +1,72 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + chat, + chatParamsFromRequestBody, + maxIterations, + memoryStream, + resumeServerSentEventsResponse, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { withChatPersistence } from '@tanstack/ai-persistence' +import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite' + +/** + * Persistent-chat demo endpoint. + * + * Two kinds of durability stack here: + * + * 1. STATE (this file) — `withChatPersistence` middleware writes the thread + * transcript, run records, and interrupt state to SQLite. The store survives + * a full server restart, so a reload can continue the same conversation from + * the server's own copy even if the client sent no history. + * + * 2. DELIVERY — `memoryStream(request)` records each chunk to an ordered log and + * tags each SSE event with an `id:` offset, so a dropped connection reconnects + * (`Last-Event-ID`) and resumes without re-running the model. Swap it for + * `durableStream(request, { server })` from `@tanstack/ai-durable-stream` in + * production (memoryStream is process-local). + */ + +// One SQLite-backed store for the whole process. `migrate: true` applies the +// bundled TanStack AI schema on first open. `.data/` is gitignored. +const persistence = sqlitePersistence({ + url: './.data/persistent-chat.db', + migrate: true, +}) + +export const Route = createFileRoute('/api/persistent-chat')({ + server: { + handlers: { + POST: async ({ request }) => { + const abortController = new AbortController() + const params = await chatParamsFromRequestBody(await request.json()) + + const stream = chat({ + adapter: openaiText('gpt-5.5'), + // `withChatPersistence` loads the stored transcript when `messages` is + // empty and overwrites it (authoritative-history contract) on finish. + middleware: [withChatPersistence(persistence)], + agentLoopStrategy: maxIterations(10), + messages: params.messages, + threadId: params.threadId, + runId: params.runId, + abortController, + }) + + return toServerSentEventsResponse(stream, { + durability: { adapter: memoryStream(request) }, + abortController, + }) + }, + + // Replay a run from the start (`?offset=-1&runId=…`) so a full reload can + // re-attach to an in-flight run by id. Read-only: no model call. + GET: ({ request }) => { + return resumeServerSentEventsResponse({ + adapter: memoryStream(request), + }) + }, + }, + }, +}) 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..898e9118f --- /dev/null +++ b/examples/ts-react-chat/src/routes/persistent-chat.tsx @@ -0,0 +1,131 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useState } from 'react' +import { + fetchServerSentEvents, + localStoragePersistence, +} from '@tanstack/ai-client' +import { useChat } from '@tanstack/ai-react' +import type { ChatPersistedState } from '@tanstack/ai-client' + +export const Route = createFileRoute('/persistent-chat')({ + component: PersistentChatPage, +}) + +const connection = fetchServerSentEvents('/api/persistent-chat') + +// One combined record (messages + resume snapshot) per chat id, in +// localStorage so it survives a full reload and browser restart. UIMessage +// carries a `Date`, which is not JSON-native, so a codec is required; strings +// round-trip fine for this demo. +const persistence = localStoragePersistence({ + serialize: (value) => JSON.stringify(value), + deserialize: (value) => JSON.parse(value), +}) + +function PersistentChatPage() { + // A stable id + threadId so a reload rehydrates the SAME conversation from + // storage (client) and continues it on the server (SQLite). + const { messages, sendMessage, isLoading, connectionStatus } = useChat({ + id: 'persistent-chat', + threadId: 'persistent-chat-thread', + connection, + persistence, + }) + const [input, setInput] = useState('Tell me a two-sentence story about a lighthouse.') + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + const text = input.trim() + if (!text || isLoading) return + setInput('') + void sendMessage(text) + } + + return ( +
+

Persistent chat

+

+ This chat persists on both ends. The client writes the transcript to + localStorage, so a full page reload restores the + conversation instantly. The server writes the same transcript, run + records, and interrupt state to SQLite via withChatPersistence, + so it survives a server restart too. Send a message, wait for the reply, + then reload the page: the conversation is still here. +

+ +
+ connection: {connectionStatus}  |  messages:{' '} + {messages.length} +
+ +
+ {messages.map((message) => ( +
+
{message.role}
+ {message.parts.map((part, index) => + part.type === 'text' && part.content ? ( +

+ {part.content} +

+ ) : null, + )} +
+ ))} +
+ +
+ setInput(e.target.value)} + placeholder="Ask something…" + style={{ flex: 1, padding: 8 }} + /> + +
+
+ ) +} + +const page: React.CSSProperties = { + maxWidth: 720, + margin: '0 auto', + padding: 24, + fontFamily: 'system-ui, sans-serif', +} + +const bubble: React.CSSProperties = { + borderRadius: 8, + padding: '10px 14px', + maxWidth: '85%', +} + +const userBubble: React.CSSProperties = { + ...bubble, + alignSelf: 'flex-end', + background: '#eef2ff', +} + +const assistantBubble: React.CSSProperties = { + ...bubble, + alignSelf: 'flex-start', + background: '#f6f6f6', +} + +const roleLabel: React.CSSProperties = { + fontSize: 11, + textTransform: 'uppercase', + letterSpacing: '0.05em', + color: '#999', + marginBottom: 4, +} From 01bc50edf83575862f86f239351c45b91e5a0ddc Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 22 Jul 2026 18:07:40 +0200 Subject: [PATCH 04/26] docs(persistence): add persistence section for server + browser durability New docs/persistence section: overview, chat-persistence, browser-refresh, controls, custom-stores, sql-backends, drizzle, prisma, cloudflare, migrations, internals. Wires the nav and updates the client chat/persistence page for the combined { messages, resume } record and built-in storage adapters. Claude-Session: https://claude.ai/code/session_01RqjWdHxvmMrhjbd8dYvENp --- docs/chat/persistence.md | 180 +++++++++------------------ docs/config.json | 62 ++++++++- docs/persistence/browser-refresh.md | 91 ++++++++++++++ docs/persistence/chat-persistence.md | 89 +++++++++++++ docs/persistence/cloudflare.md | 175 ++++++++++++++++++++++++++ docs/persistence/controls.md | 76 +++++++++++ docs/persistence/custom-stores.md | 161 ++++++++++++++++++++++++ docs/persistence/drizzle.md | 164 ++++++++++++++++++++++++ docs/persistence/internals.md | 117 +++++++++++++++++ docs/persistence/migrations.md | 91 ++++++++++++++ docs/persistence/overview.md | 112 +++++++++++++++++ docs/persistence/prisma.md | 148 ++++++++++++++++++++++ docs/persistence/sql-backends.md | 77 ++++++++++++ 13 files changed, 1420 insertions(+), 123 deletions(-) create mode 100644 docs/persistence/browser-refresh.md create mode 100644 docs/persistence/chat-persistence.md create mode 100644 docs/persistence/cloudflare.md create mode 100644 docs/persistence/controls.md create mode 100644 docs/persistence/custom-stores.md create mode 100644 docs/persistence/drizzle.md create mode 100644 docs/persistence/internals.md create mode 100644 docs/persistence/migrations.md create mode 100644 docs/persistence/overview.md create mode 100644 docs/persistence/prisma.md create mode 100644 docs/persistence/sql-backends.md diff --git a/docs/chat/persistence.md b/docs/chat/persistence.md index 985c8aa2d..8c39be120 100644 --- a/docs/chat/persistence.md +++ b/docs/chat/persistence.md @@ -2,7 +2,7 @@ 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." +description: "Persist chat conversations on the client with TanStack AI — restore messages and rejoin an in-flight run after a full page reload using a getItem/setItem/removeItem adapter." keywords: - tanstack ai - persistence @@ -13,141 +13,77 @@ keywords: - 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. +By default a `ChatClient` (and every framework `useChat` / `createChat` wrapper) +keeps messages in memory only, so a reload loses the conversation. The optional +`persistence` adapter wires the client to a storage backend so a full page +reload restores the transcript, rehydrates any pending interrupt, and rejoins a +run that was still streaming, 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. +This is the client half of persistence. For the authoritative server-side copy +(and the SQL / Cloudflare backends), see the +[Persistence section](../persistence/overview). + +## Use a built-in storage adapter + +The quickest path is one of the adapters exported from `@tanstack/ai-client`. +Give the chat a stable `id` so a reload finds the same record: + +```tsx +import { fetchServerSentEvents, localStoragePersistence } from '@tanstack/ai-client' +import { useChat } from '@tanstack/ai-react' +import type { ChatPersistedState } from '@tanstack/ai-client' + +const persistence = localStoragePersistence({ + serialize: (value) => JSON.stringify(value), + deserialize: (value) => JSON.parse(value), +}) + +function Chat() { + const { messages, sendMessage } = useChat({ + id: 'support-chat', + connection: fetchServerSentEvents('/api/chat'), + persistence, + }) + // ... +} +``` + +`localStoragePersistence`, `sessionStoragePersistence`, and +`indexedDBPersistence` all ship from `@tanstack/ai-client`. See +[Browser refresh](../persistence/browser-refresh) for choosing between them and +for rejoining an in-flight run after reload. ## 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`: +A persistence adapter is any object with three methods — the same +`getItem` / `setItem` / `removeItem` shape used elsewhere in TanStack AI. It +stores one combined record per chat id: the message transcript plus an optional +resume snapshot (which run to rejoin, which interrupts are pending). Each method +may be synchronous or return a `Promise`: ```typescript -import type { UIMessage } from "@tanstack/ai-client"; +import type { + ChatPersistedState, + UIMessage, +} from '@tanstack/ai-client' interface ChatClientPersistence { getItem: ( id: string, ) => + | ChatPersistedState + // A bare `UIMessage[]` is still accepted for backward compatibility. | Array | null | undefined - | Promise | null | undefined>; - setItem: (id: string, messages: Array) => void | Promise; - removeItem: (id: string) => void | Promise; + | Promise | null | undefined> + setItem: (id: string, state: ChatPersistedState) => 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. +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. Writes are +best-effort: a throwing or rejecting adapter is swallowed so storage problems +never break the chat. diff --git a/docs/config.json b/docs/config.json index 592302cbc..5ff2a5a29 100644 --- a/docs/config.json +++ b/docs/config.json @@ -179,7 +179,7 @@ "label": "Persistence", "to": "chat/persistence", "addedAt": "2026-06-02", - "updatedAt": "2026-07-14" + "updatedAt": "2026-07-22" } ] }, @@ -238,6 +238,66 @@ } ] }, + { + "label": "Persistence", + "children": [ + { + "label": "Overview", + "to": "persistence/overview", + "addedAt": "2026-07-22" + }, + { + "label": "Chat Persistence", + "to": "persistence/chat-persistence", + "addedAt": "2026-07-22" + }, + { + "label": "Browser Refresh", + "to": "persistence/browser-refresh", + "addedAt": "2026-07-22" + }, + { + "label": "Controls", + "to": "persistence/controls", + "addedAt": "2026-07-22" + }, + { + "label": "Custom Stores", + "to": "persistence/custom-stores", + "addedAt": "2026-07-22" + }, + { + "label": "SQL Backends", + "to": "persistence/sql-backends", + "addedAt": "2026-07-22" + }, + { + "label": "Drizzle", + "to": "persistence/drizzle", + "addedAt": "2026-07-22" + }, + { + "label": "Prisma", + "to": "persistence/prisma", + "addedAt": "2026-07-22" + }, + { + "label": "Cloudflare", + "to": "persistence/cloudflare", + "addedAt": "2026-07-22" + }, + { + "label": "Migrations", + "to": "persistence/migrations", + "addedAt": "2026-07-22" + }, + { + "label": "Internals", + "to": "persistence/internals", + "addedAt": "2026-07-22" + } + ] + }, { "label": "Protocol", "children": [ diff --git a/docs/persistence/browser-refresh.md b/docs/persistence/browser-refresh.md new file mode 100644 index 000000000..a8844a79f --- /dev/null +++ b/docs/persistence/browser-refresh.md @@ -0,0 +1,91 @@ +--- +title: Browser Refresh +id: browser-refresh +--- + +# Durability across a browser refresh + +Someone is mid-conversation and hits reload, or their tab crashes and reopens. +Without durability the chat comes back empty and any in-flight reply is lost. +The `persistence` option on `useChat` fixes this from the client side: it writes +the conversation to browser storage, so a full page reload restores the +messages, rehydrates any pending interrupt, and rejoins a run that was still +streaming. + +## Restore the conversation on reload + +Pass a storage adapter as `persistence` and give the chat a stable `id` so a +reload finds the same record: + +```tsx +import { fetchServerSentEvents, localStoragePersistence } from '@tanstack/ai-client' +import { useChat } from '@tanstack/ai-react' +import type { ChatPersistedState } from '@tanstack/ai-client' + +// One record per chat id: messages + resume snapshot, in localStorage so it +// survives a reload and a browser restart. UIMessage carries a Date, which is +// not JSON-native, so a serialize/deserialize pair is required. +const persistence = localStoragePersistence({ + serialize: (value) => JSON.stringify(value), + deserialize: (value) => JSON.parse(value), +}) + +const connection = fetchServerSentEvents('/api/chat') + +function Chat() { + const { messages, sendMessage } = useChat({ + id: 'support-chat', + connection, + persistence, + }) + // ...render messages, call sendMessage(text) +} +``` + +That is the whole opt-in. On the next load `useChat` reads the record, restores +the transcript before the first paint, and rehydrates any interrupt that was +still pending so the approval UI comes back exactly as it was. + +## Pick a storage backend + +Three adapters ship from `@tanstack/ai-client`, all with the same shape: + +- `localStoragePersistence` persists across reloads and browser restarts. +- `sessionStoragePersistence` is scoped to the tab and cleared when it closes. +- `indexedDBPersistence` is async and stores via structured clone, so a `Date` + round-trips with no codec: + +```tsx +import { indexedDBPersistence } from '@tanstack/ai-client' +import type { ChatPersistedState } from '@tanstack/ai-client' + +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. + +## Rejoin an in-flight run + +If the run was still streaming when the page reloaded, the client can re-attach +to it instead of showing a frozen half-reply. This needs a durable connection: +a route that records the stream to a durability log and exposes a GET replay +handler (see [Resumable streams](../resumable-streams/overview)). Given that, +`useChat` finds the persisted in-flight run on load and rejoins it through +`joinRun`, replaying from the server's log so the reply finishes where it left +off. No extra client code is needed beyond a resumable connection. + +## Every framework, no extra code + +Durability rides the existing `persistence` option, so it works the same in +`@tanstack/ai-react`, `-solid`, `-vue`, `-svelte`, `-angular`, and `-preact`. +Pass `persistence` (and a stable `id`) to `useChat` / `injectChat`; there is +nothing framework-specific to wire. + +## Client and server are independent + +Browser persistence restores what one browser rendered. Server persistence +([Chat Persistence](./chat-persistence)) keeps the authoritative copy for every +user and survives a server restart. Use both: the client for instant reload +restore, the server for the durable record of record. diff --git a/docs/persistence/chat-persistence.md b/docs/persistence/chat-persistence.md new file mode 100644 index 000000000..de866b17f --- /dev/null +++ b/docs/persistence/chat-persistence.md @@ -0,0 +1,89 @@ +--- +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. `withChatPersistence` 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 +import { + chat, + chatParamsFromRequestBody, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { withChatPersistence } from '@tanstack/ai-persistence' +import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite' + +// One store for the whole process. `migrate: true` applies the bundled schema. +const persistence = sqlitePersistence({ + url: 'file:.data/chat.sqlite', + migrate: true, +}) + +export async function POST(request: Request) { + const params = await chatParamsFromRequestBody(await request.json()) + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages: params.messages, + threadId: params.threadId, + runId: params.runId, + // Forward the resume batch so a thread with pending interrupts continues. + ...(params.resume ? { resume: params.resume } : {}), + middleware: [withChatPersistence(persistence)], + }) + return toServerSentEventsResponse(stream) +} +``` + +The middleware uses whichever stores the backend provides, no feature flags: + +- `messages` loads and saves the full model-message thread. +- `runs` records running, completed, failed, or interrupted status. +- `interrupts` records pending tool-approval / client-tool / generic waits, and + requires `runs`. +- `locks` is handed to other middleware for cross-worker coordination. + +`migrate: true` is convenient for local development. In production, apply the +bundled migrations through your deployment workflow instead. See +[Migrations](./migrations). + +## Send the full transcript, or none of it + +`withChatPersistence` 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. + +## Interrupts survive a restart + +When a run pauses on an interrupt (a tool approval, a client-side tool, a +generic wait), the middleware records it. A later request on that thread must +carry a `resume` batch that answers the pending interrupts before new input is +accepted, otherwise it is rejected, which is why the example above forwards +`params.resume`. The chat engine itself rebuilds the resume state from that +batch and the interrupt bindings in the loaded history, so the persistence layer +only records the interrupts and gates the thread. + +## Where to go next + +- Bring durability to the browser too, so a full page reload restores the + conversation and rejoins an in-flight run: [Browser refresh](./browser-refresh). +- 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/cloudflare.md b/docs/persistence/cloudflare.md new file mode 100644 index 000000000..0b26d5842 --- /dev/null +++ b/docs/persistence/cloudflare.md @@ -0,0 +1,175 @@ +--- +title: Cloudflare Persistence +id: cloudflare +--- + +# Cloudflare Persistence + +`@tanstack/ai-persistence-cloudflare` maps state stores to Cloudflare-native +primitives: + +| Binding | Stores | +| --- | --- | +| D1 | `messages`, `runs`, `interrupts`, `metadata` | +| Durable Objects | `locks` | + +Pass only the bindings you need. The return type contains exactly the stores +those bindings can provide. + +This package persists state. It does not provide a stream-delivery adapter; +stream re-attach / delivery durability is a separate transport-layer feature +([Resumable Streams](../resumable-streams/overview)). + +## Configure bindings + +```jsonc +// wrangler.jsonc +{ + "d1_databases": [ + { + "binding": "AI_STATE", + "database_name": "tanstack-ai-state", + "database_id": "", + "migrations_dir": "migrations" + } + ], + "durable_objects": { + "bindings": [ + { + "name": "AI_LOCKS", + "class_name": "CloudflareLockDurableObject" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["CloudflareLockDurableObject"] + } + ] +} +``` + +Re-export the lock Durable Object from your Worker entry: + +```ts +export { CloudflareLockDurableObject } from '@tanstack/ai-persistence-cloudflare' +``` + +## Create persistence + +```ts ignore +import { cloudflarePersistence } from '@tanstack/ai-persistence-cloudflare' + +interface Env { + AI_STATE: D1Database + AI_LOCKS: DurableObjectNamespace +} + +export function createPersistence(env: Env) { + return cloudflarePersistence({ + d1: env.AI_STATE, + durableObjects: env.AI_LOCKS, + lockOptions: { + leaseDurationMs: 30_000, + retryDelayMs: 50, + }, + }) +} +``` + +D1 stores structured records in SQLite tables. Each lock key is routed to a +Durable Object, which serializes owners and uses leases/alarms for recovery. + +## Use it with chat + +```ts ignore +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { withChatPersistence } from '@tanstack/ai-persistence' +import { createPersistence } from './persistence' + +interface Env { + AI_STATE: D1Database + AI_LOCKS: DurableObjectNamespace +} + +export default { + async fetch(request: Request, env: Env) { + try { + const params = await chatParamsFromRequest(request) + const persistence = createPersistence(env) + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages: params.messages, + threadId: params.threadId, + runId: params.runId, + ...(params.resume ? { resume: params.resume } : {}), + middleware: [withChatPersistence(persistence)], + }) + + return toServerSentEventsResponse(stream) + } catch (error) { + if (error instanceof Response) return error + throw error + } + }, +} +``` + +## Apply D1 migrations + +The package exports an ordered `d1Migrations` manifest and a CLI. Copy the SQL +into the directory managed by Wrangler: + +```bash +pnpm exec tanstack-ai-cloudflare-migrations --out migrations +wrangler d1 migrations apply tanstack-ai-state --local +wrangler d1 migrations apply tanstack-ai-state --remote +``` + +Use `--stdout` to print the ordered SQL. Existing divergent files are not +overwritten unless `--force` is passed. Commit the copied migrations and apply +them before deploying code that uses the stores. + +Programmatic tooling can read the same manifest: + +```ts +import { d1Migrations } from '@tanstack/ai-persistence-cloudflare' + +for (const migration of d1Migrations) { + console.log(migration.filename) +} +``` + +## Override selected stores + +Use Cloudflare as the base and replace only application-owned stores: + +```ts ignore +import { composePersistence } from '@tanstack/ai-persistence' +import { createPersistence } from './persistence' +import { customInterrupts, customRuns } from './stores' + +interface Env { + AI_STATE: D1Database + AI_LOCKS: DurableObjectNamespace +} + +export function createComposedPersistence(env: Env) { + return composePersistence(createPersistence(env), { + overrides: { + interrupts: customInterrupts, + runs: customRuns, + }, + }) +} +``` + +D1 continues to own messages and metadata, and Durable Objects own locks. +Cross-backend transactions are not added by composition; design retries and +consistency explicitly. diff --git a/docs/persistence/controls.md b/docs/persistence/controls.md new file mode 100644 index 000000000..05ef9c2c2 --- /dev/null +++ b/docs/persistence/controls.md @@ -0,0 +1,76 @@ +--- +title: Persistence Controls +id: controls +--- + +# Persistence Controls + +Persistence has no feature flags. What you persist is decided by which stores +the backend provides, and you compose backends per store. Supply only the +stores your workflow needs. + +## What each need maps to + +| Requirement | Store or option | +| --- | --- | +| Restore messages / interrupts after a browser reload | `useChat({ persistence })` (client) | +| Authoritative server transcript | `messages` | +| Run status and usage | `runs` | +| Durable approvals or human input | `interrupts` (requires `runs`) | +| App or integration checkpoints | `metadata` | +| Cross-worker coordination | `locks` | +| Replay an in-flight response | [Resumable streams](../resumable-streams/overview) (transport feature, not a store) | + +`withChatPersistence(persistence)` and `withGenerationPersistence(persistence)` +inspect the stores that are present. Store presence is the whole capability +selection mechanism. + +## Compose and override stores + +`composePersistence` takes the base backend first and an overrides object +second. Here it starts from the in-memory reference backend and swaps in custom +`interrupts` / `runs` stores: + +```ts +import { composePersistence, memoryPersistence } from '@tanstack/ai-persistence' +// Your own store implementations of the InterruptStore / RunStore contracts. +import { interruptStore, runStore } from './stores' + +const persistence = composePersistence(memoryPersistence(), { + overrides: { + interrupts: interruptStore, + runs: runStore, + }, +}) +``` + +Each override is independent: + +| Override value | Result | +| --- | --- | +| key omitted | Inherit the base store. | +| `undefined` | Inherit the base store. | +| a store object | Replace that store only. | +| `false` | Remove that store. | + +```ts +import { composePersistence, memoryPersistence } from '@tanstack/ai-persistence' + +// Drop the locks store entirely; the resulting type has no `locks` key. +const withoutLocks = composePersistence(memoryPersistence(), { + overrides: { locks: false }, +}) +``` + +Unknown store names fail type checking, and are also rejected at runtime when +values arrive from untyped JavaScript. + +## Valid store combinations + +`interrupts` requires `runs`: an interrupt record is scoped to a run, so +`withChatPersistence` rejects a persistence object that has `interrupts` without +`runs`. Everything else is independent, add stores as the workflow needs them. + +To define a partial backend directly rather than by composing, use +`defineAIPersistence({ stores: { ... } })` and pass only the stores you have. +See [Custom stores](./custom-stores) for the store contracts. diff --git a/docs/persistence/custom-stores.md b/docs/persistence/custom-stores.md new file mode 100644 index 000000000..cf5654a9e --- /dev/null +++ b/docs/persistence/custom-stores.md @@ -0,0 +1,161 @@ +--- +title: Custom Stores +id: custom-stores +--- + +# Custom Persistence Stores + +Implement custom stores when your infrastructure is not covered by the +packaged backends or when selected data must remain in an application-owned +database. + +## Define the stores you provide + +```ts +import { defineAIPersistence } from '@tanstack/ai-persistence' +import { messages, runs } from './stores' + +export const persistence = defineAIPersistence({ + stores: { messages, runs }, +}) +``` + +`defineAIPersistence` preserves the exact store keys in the type and rejects +unknown keys at runtime. Middleware behavior follows the keys that exist. + +## Store interfaces + +Each interface below is the public contract from `@tanstack/ai-persistence`. +Implement only the stores you need. + +### Messages + +```ts +import type { ModelMessage } from '@tanstack/ai' + +interface MessageStore { + loadThread(threadId: string): Promise> + saveThread( + threadId: string, + messages: Array, + ): Promise +} +``` + +`saveThread` receives the full authoritative model-message history, not a +delta. `loadThread` returns `[]` (never `null`) for a thread that was never +saved. + +### Runs + +```ts +import type { RunRecord } from '@tanstack/ai-persistence' + +interface RunStore { + createOrResume(input: { + runId: string + threadId: string + status?: 'running' | 'completed' | 'failed' | 'interrupted' + startedAt: number + }): Promise + update( + runId: string, + patch: Partial< + Pick + >, + ): Promise + get(runId: string): Promise +} +``` + +Implement `createOrResume` idempotently: a second call for an existing `runId` +returns the stored record unchanged, which is what makes resuming a run safe. +`update` against an unknown `runId` is a no-op. Retries may repeat the same run +id. + +### Interrupts + +```ts +import type { InterruptRecord } from '@tanstack/ai-persistence' + +interface InterruptStore { + create(record: Omit): Promise + resolve(interruptId: string, response?: unknown): Promise + cancel(interruptId: string): Promise + get(interruptId: string): Promise + list(threadId: string): Promise> + listPending(threadId: string): Promise> + listByRun(runId: string): Promise> + listPendingByRun(runId: string): Promise> +} +``` + +`create` accepts a record without `status`/`resolvedAt` so every interrupt is +born `'pending'`; it is insert-if-absent, so a duplicate `create` never clobbers +an already-resolved interrupt. The `list*` methods return records ordered by +`requestedAt` ascending. An `interrupts` store requires a `runs` store when used +with chat persistence. + +### Metadata + +```ts +interface MetadataStore { + get(scope: string, key: string): Promise + set(scope: string, key: string, value: unknown): Promise + delete(scope: string, key: string): Promise +} +``` + +Namespaces and value schemas are application-owned. `(scope, key)` is the +composite identity. Because a stored `null` is indistinguishable from absence at +the type level, wrap a value you must persist as `null` (e.g. `{ value: null }`). + +### Locks + +`LockStore` comes from `@tanstack/ai-persistence`. Use it to serialize work that +may run on multiple workers. A lock implementation should use leases or another +recovery mechanism so a crashed owner cannot block forever. `withLock` passes an +`AbortSignal` to the critical section. Lease-backed implementations abort that +signal when ownership can no longer be guaranteed; callbacks must stop starting +external mutations and pass the signal to cancellable dependencies. The package +ships an in-process `InMemoryLockStore` for single-process use. + +## Example message store + +```ts +import type { MessageStore } from '@tanstack/ai-persistence' +import type { ModelMessage } from '@tanstack/ai' + +const threads = new Map>() + +export const messages: MessageStore = { + async loadThread(threadId) { + return [...(threads.get(threadId) ?? [])] + }, + async saveThread(threadId, nextMessages) { + threads.set(threadId, [...nextMessages]) + }, +} +``` + +For durable infrastructure, preserve the same semantics with database +transactions, conditional writes, or stable idempotency keys. + +## Override selected packaged stores + +```ts ignore +import { composePersistence } from '@tanstack/ai-persistence' +import { cloudflarePersistence } from '@tanstack/ai-persistence-cloudflare' +import { interrupts, runs } from './stores' + +export function createPersistence(state: D1Database) { + const base = cloudflarePersistence({ d1: state }) + return composePersistence(base, { + overrides: { interrupts, runs }, + }) +} +``` + +Only those two stores move to the custom database; D1 still owns messages and +metadata. Composition does not create a transaction across those systems; +design related writes accordingly. diff --git a/docs/persistence/drizzle.md b/docs/persistence/drizzle.md new file mode 100644 index 000000000..fd0d34e66 --- /dev/null +++ b/docs/persistence/drizzle.md @@ -0,0 +1,164 @@ +--- +title: Persistence with Drizzle +id: drizzle +--- + +# Persistence with Drizzle + +`@tanstack/ai-persistence-drizzle` supports SQLite-family Drizzle databases. +It has two entry points: + +- the package root accepts an already-created, migrated `DrizzleSqliteDb` and is + safe to import in edge runtimes; +- `/sqlite` is a Node-only convenience factory built on `node:sqlite`. + +There is no dialect option. For MySQL, PostgreSQL, or another Drizzle dialect, +implement the `AIPersistence` stores for that database or use the Prisma +backend. + +## Node SQLite + +```ts +import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite' + +export const persistence = sqlitePersistence({ + url: 'file:.tanstack-ai/state.sqlite', + migrate: true, +}) +``` + +`url` may be `:memory:`, a filesystem path, or a `file:`-prefixed path. +`migrate: true` applies the bundled migrations before creating stores. Prefer +deployment-time migrations in production. + +## Bring your own SQLite Drizzle database + +```ts ignore +import { drizzle } from 'drizzle-orm/d1' +import { drizzlePersistence, schema } from '@tanstack/ai-persistence-drizzle' + +export function createPersistence(state: D1Database) { + const db = drizzle(state, { schema }) + return drizzlePersistence(db) +} +``` + +The root entry does not import Node built-ins and works with Cloudflare D1 and +other SQLite-compatible Drizzle drivers. The application owns connection +lifecycle and migration timing. + +## Use the middleware + +```ts +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { withChatPersistence } 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: [withChatPersistence(persistence)], + }) + + return toServerSentEventsResponse(stream) +} +``` + +`drizzlePersistence` provides all state stores except `locks`: this backend has +no distributed lock, so consumers that need one fall back to an in-process +lock. When multiple workers must share a lock service, use `composePersistence` +to add a distributed `locks` implementation — for example the Cloudflare +Durable Object lock from `@tanstack/ai-persistence-cloudflare`. + +## Get the migrations + +The package exports the ordered `sqliteMigrations` manifest: + +```ts +import { sqliteMigrations } from '@tanstack/ai-persistence-drizzle' + +for (const migration of sqliteMigrations) { + console.log(migration.id, migration.filename) +} +``` + +Or copy canonical SQL files with the CLI: + +```bash +pnpm exec tanstack-ai-drizzle-migrations --out migrations/tanstack-ai +``` + +Use `--stdout` to print the SQL. The CLI refuses to replace a divergent file +unless `--force` is passed. Commit the copied files and apply them using your +normal SQLite, D1, or Drizzle deployment workflow. + +## Schema ownership + +The exported `schema` contains `messages`, `runs`, `interrupts`, and +`metadata`. Application tables may live beside these tables in the same +database. + +## Own the schema + +If your project already uses drizzle-kit, you can own the TanStack AI schema +outright instead of applying the bundled SQL. Emit the schema module into your +project: + +```bash +pnpm exec tanstack-ai-drizzle-schema --out src/db +``` + +This writes `src/db/tanstack-ai-schema.ts` — a regular Drizzle schema file that +imports from **your** installed `drizzle-orm`. The CLI refuses to replace a +divergent file unless `--force` is passed; `--stdout` prints the module instead. + +Add the file to your drizzle-kit schema paths so your own migration journal +owns the DDL: + +```ts ignore +import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + dialect: 'sqlite', + schema: ['./src/db/schema.ts', './src/db/tanstack-ai-schema.ts'], + out: './drizzle', +}) +``` + +Then pass the schema back so the runtime reads and writes through your copy: + +```ts +import { drizzlePersistence } from '@tanstack/ai-persistence-drizzle' +import { schema } from './tanstack-ai-schema' +import { db } from './db' + +export const persistence = drizzlePersistence(db, { schema }) +``` + +Because the runtime operates on the table objects you pass, the file is truly +yours to shape: + +- **Rename tables and columns**, or drop the explicit column names and rely on + your drizzle `casing` configuration — the stores read database names from + your objects, so the generated SQL follows your conventions. +- **Add app-owned columns** — for example a `userId` column on `messages` to + scope threads to users. Keep added columns nullable or defaulted so the + store inserts succeed; the TanStack AI stores never read or write them. +- **Keep the contract columns** with their data shapes. The + `TanstackAiSqliteSchema` type enforces the shapes at compile time, and + `drizzlePersistence` validates the tables and columns exist at construction. + +When you own the schema this way, migrations flow entirely through your +drizzle-kit journal — package upgrades that change the schema surface as +drizzle-kit diffs when you update the emitted file. Don't mix this with the +bundled SQL migrations: pick one DDL owner per database. diff --git a/docs/persistence/internals.md b/docs/persistence/internals.md new file mode 100644 index 000000000..439118a91 --- /dev/null +++ b/docs/persistence/internals.md @@ -0,0 +1,117 @@ +--- +title: Persistence Internals +id: internals +--- + +# Persistence Internals + +This page describes the contracts between middleware, state stores, and +browser storage. + +## Two separate boundaries + +```text +Application state Browser hydration +----------------- ----------------- +AIPersistence stores ChatStorageAdapter +middleware lifecycle UIMessage/snapshot +server authoritative client convenience +``` + +Stream delivery (replaying an in-flight SSE response) is a third, transport-only +boundary — [Resumable Streams](../resumable-streams/overview) — and +intentionally shares nothing with these two. State middleware never mutates +chunks to add delivery offsets, and browser chat persistence stores rendered +messages or interrupt resume snapshots, not server event logs. + +## Chat middleware lifecycle + +`withChatPersistence(persistence)` derives a plan from store presence: + +1. `setup` provides persistence, interrupt, and lock capabilities when their + stores exist. +2. `onConfig` creates or resumes the run, loads pending interrupts, and + validates the request's resume batch against them, then merges stored + messages into the request when the request carries no history. +3. `onChunk` reacts only to a `RUN_FINISHED` interrupt outcome by committing + the accepted resumes, storing the new interrupts, marking the run + interrupted, and saving messages. +4. `onFinish`, `onError`, and `onAbort` terminalize the run record. + +Accepted resumes are committed (interrupts marked resolved/cancelled) only once +the run reaches a successful boundary, so a provider failure or abort between +accepting a resume and reaching that boundary leaves the interrupt pending and a +retry with the same resume succeeds. The canonical AG-UI chunk stream remains +unchanged; persistence does not create a second event stream. + +When a request carries a non-empty `messages` array it is treated as the full +authoritative history and, on finish, overwrites the stored thread. To continue +a stored thread without resending history, pass an empty `messages` array — the +stored transcript is loaded and used. + +## Generation middleware lifecycle + +`withGenerationPersistence(persistence)` records the run: `onStart` creates or +resumes the run record, and `onFinish`, `onError`, and `onAbort` terminalize +it. Durable media storage (artifact metadata plus blob bytes) is a follow-up +feature. + +## Composition semantics + +```ts +import { + composePersistence, + memoryPersistence, +} from '@tanstack/ai-persistence' + +const base = memoryPersistence() +const replacement = base.stores.messages + +const result = composePersistence(base, { + overrides: { + messages: replacement, + metadata: undefined, + locks: false, + }, +}) +``` + +- `messages` is replaced. +- `metadata` is inherited because the override is `undefined`. +- `locks` is removed. +- every omitted store is inherited. + +Composition copies the store map and does not mutate or dispose either input. +The return type calculates which keys are required, optional, replaced, or +removed. Unknown store keys are rejected statically and by runtime validation. + +Middleware adds capability validation: + +- chat rejects `interrupts` without `runs`. + +The runtime checks are required because JavaScript, configuration loading, and +explicitly widened types can bypass static guarantees. + +## Backend ownership + +Packaged backends own resources differently: + +- Drizzle accepts a migrated SQLite-family database; its root import is + edge-safe. The `/sqlite` entry creates a Node SQLite connection. +- Prisma accepts the application's generated and migrated client. +- Cloudflare maps D1 to structured stores and Durable Objects to locks. + +`composePersistence` does not add distributed transactions. When related +stores use different systems, adapter authors must define retry, +idempotency, and consistency behavior. + +## Browser storage + +`ChatStorageAdapter` allows synchronous or asynchronous `getItem`, +`setItem`, and `removeItem`. The chat persistence controller orders writes and +owns hydration/cleanup independently of the main `ChatClient` stream logic. + +Web Storage adapters serialize strings and require a codec for non-JSON values. +IndexedDB uses structured clone. All three built-ins throw +`StorageUnavailableError` when their browser API is unavailable, including +SSR. diff --git a/docs/persistence/migrations.md b/docs/persistence/migrations.md new file mode 100644 index 000000000..f68d64f1b --- /dev/null +++ b/docs/persistence/migrations.md @@ -0,0 +1,91 @@ +--- +title: Persistence Migrations +id: migrations +--- + +# Persistence Migrations + +Migration ownership depends on the backend. Apply schema changes before +deploying code that reads or writes the corresponding stores. + +## Drizzle SQLite + +The Drizzle package bundles ordered canonical SQLite migrations. Copy them into +your repository: + +```bash +pnpm exec tanstack-ai-drizzle-migrations --out migrations/tanstack-ai +``` + +Or print the ordered SQL: + +```bash +pnpm exec tanstack-ai-drizzle-migrations --stdout +``` + +The CLI preserves existing identical files and refuses to overwrite divergent +files without `--force`. Apply the copied SQL using your normal SQLite, +Drizzle, or D1 deployment process. + +For local Node development, the `/sqlite` factory can apply the same manifest: + +```ts +import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite' + +const persistence = sqlitePersistence({ + url: 'file:.tanstack-ai/state.sqlite', + migrate: true, +}) +``` + +Avoid request-time migrations in production. + +Projects that already run drizzle-kit can skip the bundled SQL entirely: emit +the schema module with `tanstack-ai-drizzle-schema`, add it to your drizzle-kit +schema paths, and let your own journal generate the DDL. See +[Own the schema](./drizzle#own-the-schema). Pick one DDL owner per database — +bundled SQL or your journal, not both. + +## Cloudflare D1 + +The Cloudflare package provides D1-specific migration assets: + +```bash +pnpm exec tanstack-ai-cloudflare-migrations --out migrations +wrangler d1 migrations apply tanstack-ai-state --remote +``` + +It also exports `d1Migrations` for programmatic tooling. Durable Object locks +do not use the D1 table migration set; configure their bindings and Durable +Object migration tags in Wrangler. + +## Prisma + +Prisma ships a provider-neutral models fragment, then delegates SQL generation +to your application: + +```bash +pnpm exec tanstack-ai-prisma-models --out prisma/schema +pnpm prisma migrate dev --name add-tanstack-ai-persistence +pnpm prisma generate +pnpm prisma migrate deploy +``` + +The copied fragment contains no datasource or generator. Keep those in your +application schema and commit the native migration Prisma creates for your +provider. + +## Custom stores + +Custom `AIPersistence` adapters own their schema and migrations entirely. +Maintain compatibility with the public store records and method semantics; +TanStack AI does not inspect your table layout. + +## Upgrade discipline + +1. Read the package release notes for schema changes. +2. Refresh copied assets in a reviewable branch. +3. Inspect the diff rather than using `--force` blindly. +4. Back up production state where required. +5. Apply migrations before deploying code that depends on them. +6. Keep rollback and partial-deployment behavior explicit. diff --git a/docs/persistence/overview.md b/docs/persistence/overview.md new file mode 100644 index 000000000..cd0ea914c --- /dev/null +++ b/docs/persistence/overview.md @@ -0,0 +1,112 @@ +--- +title: Persistence Overview +id: overview +--- + +# Persistence Overview + +Persistence stores authoritative state — messages, runs, pending interrupts, +metadata, and locks. It is configured with persistence middleware. + +Reconnecting to an in-flight streaming response is a separate transport-layer +feature ([Resumable Streams](../resumable-streams/overview)), not part of this +middleware. Persisting state does not automatically make a live response +replayable, and a replayable response does not replace authoritative +application state. + +## Store contract + +An `AIPersistence` object can expose these stores: + +| Store | Purpose | +| --- | --- | +| `messages` | Authoritative model-message history per thread. | +| `runs` | Run status, timing, errors, and usage. | +| `interrupts` | Pending, resolved, or cancelled human/tool waits. | +| `metadata` | App and integration key/value state. | +| `locks` | Cross-worker coordination. | + +Middleware activates behavior from the stores that are present; there is no +separate enablement list. When chat persistence sees an `interrupts` store it +also requires a `runs` store. + +## Minimal server state persistence + +```ts +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { withChatPersistence } from '@tanstack/ai-persistence' +import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite' + +const persistence = sqlitePersistence({ + url: 'file:.tanstack-ai/state.sqlite', + migrate: true, +}) + +export async function POST(request: Request) { + const params = await chatParamsFromRequest(request) + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages: params.messages, + threadId: params.threadId, + runId: params.runId, + ...(params.resume ? { resume: params.resume } : {}), + middleware: [withChatPersistence(persistence)], + }) + + return toServerSentEventsResponse(stream) +} +``` + +The Node convenience factory is SQLite-only. For an existing SQLite-family +Drizzle database, including Cloudflare D1, use the edge-safe root +`drizzlePersistence(db)`. For another database engine, implement the +`AIPersistence` store interfaces or use Prisma. + +## Compose backends by store + +Use one backend as the first argument and replace only selected stores through +the second argument. Any `AIPersistence` can be the base — here a +`memoryPersistence()` base keeps the example self-contained, and two +application-owned stores are routed elsewhere: + +```ts +import { + composePersistence, + memoryPersistence, +} from '@tanstack/ai-persistence' +import { appInterrupts, appRuns } from './stores' + +const base = memoryPersistence() + +const persistence = composePersistence(base, { + overrides: { + interrupts: appInterrupts, + runs: appRuns, + }, +}) +``` + +An omitted or explicitly `undefined` override inherits the base store. A store +object replaces that one store. `false` removes it. The return type tracks the +resulting keys, and runtime validation rejects unknown keys from untyped +JavaScript. + +Cross-store consistency remains your responsibility. If `runs` and +`interrupts` live in separate systems, design for partial failure and retries. + +## Choose a backend + +- [Cloudflare](./cloudflare): D1 structured state and Durable Object locks. +- [Drizzle](./drizzle): SQLite-family Drizzle databases and a Node SQLite + convenience factory. +- [Prisma](./prisma): provider-neutral Prisma models with your migrated client. +- [Custom Stores](./custom-stores): implement only the store contracts you + need. + +For the shared mechanics behind every backend — the middleware lifecycle and +composition semantics — see [Persistence Internals](./internals). diff --git a/docs/persistence/prisma.md b/docs/persistence/prisma.md new file mode 100644 index 000000000..7cf19666b --- /dev/null +++ b/docs/persistence/prisma.md @@ -0,0 +1,148 @@ +--- +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. + +## 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 { withChatPersistence } 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: [withChatPersistence(persistence)], + }) + + return toServerSentEventsResponse(stream) +} +``` + +`prismaPersistence` provides no `locks` store: this backend has no distributed +lock, so consumers that need one fall back to an in-process lock. Use +`composePersistence` to add a distributed `locks` implementation — for example +the Cloudflare Durable Object lock from `@tanstack/ai-persistence-cloudflare` — +or to route selected stores to another system. Composition does not add a +transaction across multiple backends. + +## Model layout + +The fragment maps four persisted store contracts to `Message`, `Run`, +`Interrupt`, and `Metadata` models. IDs and timestamps use portable Prisma +types so the application can generate migrations for its chosen provider. + +## Rename the models + +The fragment is yours once copied, and its model names are generic — an +application often already has a `Message` or `Run` model. Rename the TanStack +AI models freely and map each store to the renamed client delegate: + +```prisma +/// Renamed from `Message` to avoid a collision. +model ChatMessage { + threadId String @id @map("thread_id") + messagesJson String @map("messages_json") + + @@map("messages") +} +``` + +```ts ignore +import { PrismaClient } from '@prisma/client' +import { prismaPersistence } from '@tanstack/ai-persistence-prisma' + +const prisma = new PrismaClient() + +export const persistence = prismaPersistence(prisma, { + models: { messages: 'chatMessage' }, +}) +``` + +Map values are the camelCase client accessor names (`prisma.chatMessage`), not +the PascalCase model names. Unmapped stores keep their default names +(`message`, `run`, `interrupt`, `metadata`), and `prismaPersistence` throws a +`PrismaModelError` naming every store whose delegate cannot be found. + +What stays fixed is the client-level field surface: keep the fragment's field +names and types, and the default composite unique alias `scope_key` on the +metadata model. Everything else is yours: + +- **Database names** — table and column names are already governed by + `@@map` / `@map` in your copy; change them without touching the runtime. +- **Extra app-owned fields** — for example a `userId` on the messages model to + scope threads to users. Keep added fields optional or defaulted so the + store creates succeed; the TanStack AI stores never read or write them. diff --git a/docs/persistence/sql-backends.md b/docs/persistence/sql-backends.md new file mode 100644 index 000000000..0e783907a --- /dev/null +++ b/docs/persistence/sql-backends.md @@ -0,0 +1,77 @@ +--- +title: SQL Backends +id: sql-backends +--- + +# SQL Backends + +TanStack AI ships two SQL-oriented state adapters with different ownership +models. + +| Adapter | Database support | Connection ownership | Schema workflow | +| --- | --- | --- | --- | +| `@tanstack/ai-persistence-drizzle` | SQLite-family only | Bring a migrated Drizzle DB, or use Node `/sqlite` | Bundled SQLite migration manifest and CLI | +| `@tanstack/ai-persistence-prisma` | Providers supported by your Prisma schema | Bring your generated `PrismaClient` | Copy models fragment, then use Prisma migrate | + +The Drizzle adapter does not accept a dialect selector. Its schema and stores +use SQLite APIs. For a non-SQLite Drizzle database, implement the public +`AIPersistence` store interfaces for that dialect. + +## Local SQLite + +```ts +import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite' + +export const persistence = sqlitePersistence({ + url: 'file:.tanstack-ai/state.sqlite', + migrate: true, +}) +``` + +## Existing SQLite or D1 Drizzle database + +```ts ignore +import { drizzle } from 'drizzle-orm/d1' +import { drizzlePersistence, schema } from '@tanstack/ai-persistence-drizzle' + +export function createPersistence(state: D1Database) { + const db = drizzle(state, { schema }) + return drizzlePersistence(db) +} +``` + +The package root is edge-safe; `/sqlite` is Node-only. + +## Prisma + +```ts ignore +import { PrismaClient } from '@prisma/client' +import { prismaPersistence } from '@tanstack/ai-persistence-prisma' + +const prisma = new PrismaClient() +export const persistence = prismaPersistence(prisma) +``` + +Copy the package's models fragment and create provider-native migrations before +constructing the adapter. See [Prisma](./prisma). + +## Store coverage + +Both adapters provide messages, runs, interrupts, and metadata. Neither +provides a `locks` store; add a distributed store when multiple processes can +mutate the same run. A distributed lock must implement the `LockStore` contract +from `@tanstack/ai-persistence`: + +```ts +import { composePersistence } from '@tanstack/ai-persistence' +import { persistence } from './persistence' +import { distributedLocks } from './locks' + +const coordinated = composePersistence(persistence, { + overrides: { locks: distributedLocks }, +}) +``` + +For Cloudflare-native state, [Cloudflare Persistence](./cloudflare) combines +D1 and Durable Object locks. For another SQL library, start with +[Custom Stores](./custom-stores). From 2a92c336911d58de731120b7b2e2385def9e0fc7 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 22 Jul 2026 18:07:56 +0200 Subject: [PATCH 05/26] docs(skills): cover persistence in middleware + chat-experience skills Update the agent skills for the new surface: withChatPersistence server middleware and its backends, and the client browser-refresh durability (combined persistence record, storage adapters, joinRun rejoin, all frameworks). Claude-Session: https://claude.ai/code/session_01RqjWdHxvmMrhjbd8dYvENp --- .../skills/ai-core/chat-experience/SKILL.md | 66 +++++++++++++- .../ai/skills/ai-core/middleware/SKILL.md | 90 +++++++++++++++++++ 2 files changed, 155 insertions(+), 1 deletion(-) diff --git a/packages/ai/skills/ai-core/chat-experience/SKILL.md b/packages/ai/skills/ai-core/chat-experience/SKILL.md index b6a114345..54a43d1ad 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/browser-refresh.md' --- # Chat Experience @@ -485,6 +487,68 @@ 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 `id` so the reload finds the same record. + +The storage adapters and `ChatPersistedState` are **not** re-exported by the +framework packages — import them from `@tanstack/ai-client` (the one exception +to mistake `j` below), while `useChat` still comes from the framework package: + +```typescript +import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' +import { localStoragePersistence } from '@tanstack/ai-client' +import type { ChatPersistedState } from '@tanstack/ai-client' + +// UIMessage carries a Date (not JSON-native), so pass a serialize/deserialize +// pair for the Web Storage adapters. indexedDBPersistence needs no codec. +const persistence = localStoragePersistence({ + serialize: (value) => JSON.stringify(value), + deserialize: (value) => JSON.parse(value), +}) + +function Chat() { + const { messages, sendMessage } = useChat({ + id: 'support-chat', + connection: fetchServerSentEvents('/api/chat'), + persistence, + }) + // ...render messages, call sendMessage(text) +} +``` + +**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 `withChatPersistence` +> middleware — see ai-core/middleware/SKILL.md. The two are independent; use +> both for instant reload restore plus a durable record of record. + ## Common Mistakes ### a. CRITICAL: Using Vercel AI SDK patterns (streamText, generateText) @@ -695,4 +759,4 @@ If not handled, the UI appears to hang with no feedback. - See also: **ai-core/tool-calling/SKILL.md** -- Most chats include tools - See also: **ai-core/adapter-configuration/SKILL.md** -- Adapter choice affects available features -- See also: **ai-core/middleware/SKILL.md** -- Use middleware for analytics and lifecycle events +- See also: **ai-core/middleware/SKILL.md** -- Use middleware for analytics and lifecycle events; `withChatPersistence` is the server (authoritative) half of the client `persistence` option diff --git a/packages/ai/skills/ai-core/middleware/SKILL.md b/packages/ai/skills/ai-core/middleware/SKILL.md index 65c6bf7b8..5c9220b47 100644 --- a/packages/ai/skills/ai-core/middleware/SKILL.md +++ b/packages/ai/skills/ai-core/middleware/SKILL.md @@ -12,6 +12,7 @@ library_version: '0.10.0' sources: - 'TanStack/ai:docs/advanced/middleware.md' - 'TanStack/ai:docs/sandbox/observability.md' + - 'TanStack/ai:docs/persistence/overview.md' --- # Middleware @@ -372,6 +373,94 @@ Options: `maxSize` (default 100), `ttl` (default Infinity), `toolNames` (default `keyFn` (custom cache key), `storage` (custom backend like Redis). See `docs/advanced/middleware.md` for custom storage examples. +## Server State Persistence: withChatPersistence + +`withChatPersistence(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 { withChatPersistence } from '@tanstack/ai-persistence' +import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite' + +const persistence = sqlitePersistence({ + url: 'file:.tanstack-ai/state.sqlite', + migrate: true, +}) + +export async function POST(request: Request) { + const params = await chatParamsFromRequest(request) + + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages: params.messages, + threadId: params.threadId, + runId: params.runId, + ...(params.resume ? { resume: params.resume } : {}), + middleware: [withChatPersistence(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 +`withChatPersistence`: + +| Backend | Factory | Import | +| ------------------------------------ | ------------------------------------------- | --------------------------------------------------- | +| In-memory (dev/tests) | `memoryPersistence()` | `@tanstack/ai-persistence` | +| Drizzle SQLite-family (edge-safe) | `drizzlePersistence(db)` | `@tanstack/ai-persistence-drizzle` | +| Node SQLite convenience factory | `sqlitePersistence({ url, migrate })` | `@tanstack/ai-persistence-drizzle/sqlite` | +| Prisma | `prismaPersistence(prisma)` | `@tanstack/ai-persistence-prisma` | +| Cloudflare (D1 + Durable Object locks) | `cloudflarePersistence({ d1, durableObjects })` | `@tanstack/ai-persistence-cloudflare` | + +`drizzlePersistence(db)` is the edge-safe root — pass an already-migrated +SQLite-compatible Drizzle database (including Cloudflare D1). `sqlitePersistence` +is Node-only and lives at the `/sqlite` subpath. Compose backends per store with +`composePersistence(base, { overrides })`. + +### Resume reconstruction is the engine's job, not the middleware's + +When a thread has pending interrupts, the middleware only **records** the +interrupts and **gates** new input: a request that carries pending interrupts +must include a `resume` batch that references them, or `onConfig` throws. The +middleware does **not** build `ChatResumeToolState`. The chat engine reconstructs +the actual resume tool state itself, from `config.resume` plus the interrupt +bindings carried in the server-loaded message history. Resumes accepted in +`onConfig` are committed (marked resolved/cancelled) only once the run reaches a +successful boundary, so a provider failure between accepting a resume and +finishing leaves the interrupt pending and a retry with the same resume succeeds. + +> A companion `withGenerationPersistence(persistence)` tracks run records for +> non-chat generation activities (image, audio, TTS, video, transcription). + +Source: docs/persistence/overview.md + ## Sandbox File-Event Hooks (`sandbox` group) Declare a `sandbox: ChatSandboxHooks` group on `defineChatMiddleware` to react @@ -557,3 +646,4 @@ Source: docs/advanced/middleware.md - See also: **ai-core/chat-experience/SKILL.md** -- Middleware hooks into the chat lifecycle - See also: **ai-core/structured-outputs/SKILL.md** -- Middleware now wraps the final structured-output call; use `onStructuredOutputConfig` for JSON-Schema transforms - See also: **ai-core/ag-ui-protocol/SKILL.md** -- Reading the `sandbox.file` / `sandbox.file.diff` `CUSTOM` chunks the sandbox runtime emits alongside these `sandbox` hooks, via `ChatStream`'s typed `KnownCustomEvent` narrowing +- See also: **ai-core/chat-experience/SKILL.md** -- `withChatPersistence` is the server (authoritative) half; the client `persistence` option is the browser-refresh half From c33dc5d14ae5a769d2354b5e0a29539e5e2be24a Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 22 Jul 2026 18:09:37 +0200 Subject: [PATCH 06/26] test(e2e): persistence browser-refresh durability spec Provider-free durable harness route + client page + spec proving message restore after reload and interrupt-survives-reload via localStorage. Mid-stream joinRun rejoin is covered by ai-client unit tests and delivery-durability. Claude-Session: https://claude.ai/code/session_01RqjWdHxvmMrhjbd8dYvENp --- testing/e2e/src/routeTree.gen.ts | 43 +++++ .../src/routes/api.persistence-durability.ts | 166 ++++++++++++++++++ .../e2e/src/routes/persistence-durability.tsx | 131 ++++++++++++++ .../e2e/tests/persistence-durability.spec.ts | 111 ++++++++++++ 4 files changed, 451 insertions(+) create mode 100644 testing/e2e/src/routes/api.persistence-durability.ts create mode 100644 testing/e2e/src/routes/persistence-durability.tsx create mode 100644 testing/e2e/tests/persistence-durability.spec.ts diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 8687d013b..b9d592506 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as ToolsTestRouteImport } from './routes/tools-test' +import { Route as PersistenceDurabilityRouteImport } from './routes/persistence-durability' import { Route as MiddlewareTestRouteImport } from './routes/middleware-test' import { Route as MarkdownCjkRouteImport } from './routes/markdown-cjk' import { Route as InterruptsTestRouteImport } from './routes/interrupts-test' @@ -29,6 +30,7 @@ import { Route as ApiTranscriptionRouteImport } from './routes/api.transcription import { Route as ApiToolsTestRouteImport } from './routes/api.tools-test' import { Route as ApiToolCallLifecycleWireRouteImport } from './routes/api.tool-call-lifecycle-wire' import { Route as ApiSummarizeRouteImport } from './routes/api.summarize' +import { Route as ApiPersistenceDurabilityRouteImport } from './routes/api.persistence-durability' import { Route as ApiOtelUsageRouteImport } from './routes/api.otel-usage' import { Route as ApiOtelMediaRouteImport } from './routes/api.otel-media' import { Route as ApiOpenrouterWebToolsWireRouteImport } from './routes/api.openrouter-web-tools-wire' @@ -69,6 +71,11 @@ const ToolsTestRoute = ToolsTestRouteImport.update({ path: '/tools-test', getParentRoute: () => rootRouteImport, } as any) +const PersistenceDurabilityRoute = PersistenceDurabilityRouteImport.update({ + id: '/persistence-durability', + path: '/persistence-durability', + getParentRoute: () => rootRouteImport, +} as any) const MiddlewareTestRoute = MiddlewareTestRouteImport.update({ id: '/middleware-test', path: '/middleware-test', @@ -165,6 +172,12 @@ const ApiSummarizeRoute = ApiSummarizeRouteImport.update({ path: '/api/summarize', getParentRoute: () => rootRouteImport, } as any) +const ApiPersistenceDurabilityRoute = + ApiPersistenceDurabilityRouteImport.update({ + id: '/api/persistence-durability', + path: '/api/persistence-durability', + getParentRoute: () => rootRouteImport, + } as any) const ApiOtelUsageRoute = ApiOtelUsageRouteImport.update({ id: '/api/otel-usage', path: '/api/otel-usage', @@ -353,6 +366,7 @@ export interface FileRoutesByFullPath { '/interrupts-test': typeof InterruptsTestRoute '/markdown-cjk': typeof MarkdownCjkRoute '/middleware-test': typeof MiddlewareTestRoute + '/persistence-durability': typeof PersistenceDurabilityRoute '/tools-test': typeof ToolsTestRoute '/$provider/$feature': typeof ProviderFeatureRoute '/api/anthropic-bug-test': typeof ApiAnthropicBugTestRoute @@ -383,6 +397,7 @@ export interface FileRoutesByFullPath { '/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute '/api/otel-media': typeof ApiOtelMediaRoute '/api/otel-usage': typeof ApiOtelUsageRoute + '/api/persistence-durability': typeof ApiPersistenceDurabilityRoute '/api/summarize': typeof ApiSummarizeRoute '/api/tool-call-lifecycle-wire': typeof ApiToolCallLifecycleWireRoute '/api/tools-test': typeof ApiToolsTestRoute @@ -409,6 +424,7 @@ export interface FileRoutesByTo { '/interrupts-test': typeof InterruptsTestRoute '/markdown-cjk': typeof MarkdownCjkRoute '/middleware-test': typeof MiddlewareTestRoute + '/persistence-durability': typeof PersistenceDurabilityRoute '/tools-test': typeof ToolsTestRoute '/$provider/$feature': typeof ProviderFeatureRoute '/api/anthropic-bug-test': typeof ApiAnthropicBugTestRoute @@ -439,6 +455,7 @@ export interface FileRoutesByTo { '/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute '/api/otel-media': typeof ApiOtelMediaRoute '/api/otel-usage': typeof ApiOtelUsageRoute + '/api/persistence-durability': typeof ApiPersistenceDurabilityRoute '/api/summarize': typeof ApiSummarizeRoute '/api/tool-call-lifecycle-wire': typeof ApiToolCallLifecycleWireRoute '/api/tools-test': typeof ApiToolsTestRoute @@ -466,6 +483,7 @@ export interface FileRoutesById { '/interrupts-test': typeof InterruptsTestRoute '/markdown-cjk': typeof MarkdownCjkRoute '/middleware-test': typeof MiddlewareTestRoute + '/persistence-durability': typeof PersistenceDurabilityRoute '/tools-test': typeof ToolsTestRoute '/$provider/$feature': typeof ProviderFeatureRoute '/api/anthropic-bug-test': typeof ApiAnthropicBugTestRoute @@ -496,6 +514,7 @@ export interface FileRoutesById { '/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute '/api/otel-media': typeof ApiOtelMediaRoute '/api/otel-usage': typeof ApiOtelUsageRoute + '/api/persistence-durability': typeof ApiPersistenceDurabilityRoute '/api/summarize': typeof ApiSummarizeRoute '/api/tool-call-lifecycle-wire': typeof ApiToolCallLifecycleWireRoute '/api/tools-test': typeof ApiToolsTestRoute @@ -524,6 +543,7 @@ export interface FileRouteTypes { | '/interrupts-test' | '/markdown-cjk' | '/middleware-test' + | '/persistence-durability' | '/tools-test' | '/$provider/$feature' | '/api/anthropic-bug-test' @@ -554,6 +574,7 @@ export interface FileRouteTypes { | '/api/openrouter-web-tools-wire' | '/api/otel-media' | '/api/otel-usage' + | '/api/persistence-durability' | '/api/summarize' | '/api/tool-call-lifecycle-wire' | '/api/tools-test' @@ -580,6 +601,7 @@ export interface FileRouteTypes { | '/interrupts-test' | '/markdown-cjk' | '/middleware-test' + | '/persistence-durability' | '/tools-test' | '/$provider/$feature' | '/api/anthropic-bug-test' @@ -610,6 +632,7 @@ export interface FileRouteTypes { | '/api/openrouter-web-tools-wire' | '/api/otel-media' | '/api/otel-usage' + | '/api/persistence-durability' | '/api/summarize' | '/api/tool-call-lifecycle-wire' | '/api/tools-test' @@ -636,6 +659,7 @@ export interface FileRouteTypes { | '/interrupts-test' | '/markdown-cjk' | '/middleware-test' + | '/persistence-durability' | '/tools-test' | '/$provider/$feature' | '/api/anthropic-bug-test' @@ -666,6 +690,7 @@ export interface FileRouteTypes { | '/api/openrouter-web-tools-wire' | '/api/otel-media' | '/api/otel-usage' + | '/api/persistence-durability' | '/api/summarize' | '/api/tool-call-lifecycle-wire' | '/api/tools-test' @@ -693,6 +718,7 @@ export interface RootRouteChildren { InterruptsTestRoute: typeof InterruptsTestRoute MarkdownCjkRoute: typeof MarkdownCjkRoute MiddlewareTestRoute: typeof MiddlewareTestRoute + PersistenceDurabilityRoute: typeof PersistenceDurabilityRoute ToolsTestRoute: typeof ToolsTestRoute ProviderFeatureRoute: typeof ProviderFeatureRoute ApiAnthropicBugTestRoute: typeof ApiAnthropicBugTestRoute @@ -723,6 +749,7 @@ export interface RootRouteChildren { ApiOpenrouterWebToolsWireRoute: typeof ApiOpenrouterWebToolsWireRoute ApiOtelMediaRoute: typeof ApiOtelMediaRoute ApiOtelUsageRoute: typeof ApiOtelUsageRoute + ApiPersistenceDurabilityRoute: typeof ApiPersistenceDurabilityRoute ApiSummarizeRoute: typeof ApiSummarizeRoute ApiToolCallLifecycleWireRoute: typeof ApiToolCallLifecycleWireRoute ApiToolsTestRoute: typeof ApiToolsTestRoute @@ -741,6 +768,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ToolsTestRouteImport parentRoute: typeof rootRouteImport } + '/persistence-durability': { + id: '/persistence-durability' + path: '/persistence-durability' + fullPath: '/persistence-durability' + preLoaderRoute: typeof PersistenceDurabilityRouteImport + parentRoute: typeof rootRouteImport + } '/middleware-test': { id: '/middleware-test' path: '/middleware-test' @@ -874,6 +908,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiSummarizeRouteImport parentRoute: typeof rootRouteImport } + '/api/persistence-durability': { + id: '/api/persistence-durability' + path: '/api/persistence-durability' + fullPath: '/api/persistence-durability' + preLoaderRoute: typeof ApiPersistenceDurabilityRouteImport + parentRoute: typeof rootRouteImport + } '/api/otel-usage': { id: '/api/otel-usage' path: '/api/otel-usage' @@ -1186,6 +1227,7 @@ const rootRouteChildren: RootRouteChildren = { InterruptsTestRoute: InterruptsTestRoute, MarkdownCjkRoute: MarkdownCjkRoute, MiddlewareTestRoute: MiddlewareTestRoute, + PersistenceDurabilityRoute: PersistenceDurabilityRoute, ToolsTestRoute: ToolsTestRoute, ProviderFeatureRoute: ProviderFeatureRoute, ApiAnthropicBugTestRoute: ApiAnthropicBugTestRoute, @@ -1216,6 +1258,7 @@ const rootRouteChildren: RootRouteChildren = { ApiOpenrouterWebToolsWireRoute: ApiOpenrouterWebToolsWireRoute, ApiOtelMediaRoute: ApiOtelMediaRoute, ApiOtelUsageRoute: ApiOtelUsageRoute, + ApiPersistenceDurabilityRoute: ApiPersistenceDurabilityRoute, ApiSummarizeRoute: ApiSummarizeRoute, ApiToolCallLifecycleWireRoute: ApiToolCallLifecycleWireRoute, ApiToolsTestRoute: ApiToolsTestRoute, diff --git a/testing/e2e/src/routes/api.persistence-durability.ts b/testing/e2e/src/routes/api.persistence-durability.ts new file mode 100644 index 000000000..a657d356e --- /dev/null +++ b/testing/e2e/src/routes/api.persistence-durability.ts @@ -0,0 +1,166 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + INTERRUPT_BINDING_METADATA_KEY, + INTERRUPT_BINDING_VERSION, + canonicalInterruptJson, + digestInterruptJson, + memoryStream, + resumeServerSentEventsResponse, + toServerSentEventsResponse, +} from '@tanstack/ai' +import type { StreamChunk } from '@tanstack/ai' + +/** + * Provider-free harness route for the browser-refresh persistence story. It + * mirrors the production wiring of `examples/.../api.persistent-chat.ts` — a + * `memoryStream(request)` delivery sink plus a GET resume handler that makes the + * connection resumable — but streams a FIXED AG-UI sequence instead of calling + * an LLM, so the e2e is deterministic with nothing to mock. + * + * Two scenarios (`?scenario=`): + * + * - `text` (default) — a run that streams one assistant text message and + * finishes cleanly (`outcome: success`). The client persists the transcript + * to its `localStoragePersistence` combined record; the resume half is + * cleared on the successful terminal. A reload restores the messages. + * - `interrupt` — a run that ends on a single BOUND generic interrupt + * (carrying a resume binding, exactly like `api.foreign-interrupt`). The + * client folds the pending-interrupt resume snapshot into the SAME combined + * record, so a reload rehydrates the interrupt from `localStorage` alone + * (no server round-trip). + * + * Exempt from the aimock policy: this route streams a fixed AG-UI sequence and + * never reaches an LLM provider's HTTP layer, so there is nothing to mock. + */ + +const REPLY_TEXT = 'PERSIST_OK the lighthouse still turns.' + +const confirmSchema = { + type: 'object', + properties: { confirmed: { type: 'boolean' } }, + required: ['confirmed'], +} + +function textRun(threadId: string, runId: string): AsyncIterable { + return (async function* () { + yield { + type: 'RUN_STARTED', + threadId, + runId, + timestamp: Date.now(), + } as StreamChunk + yield { + type: 'TEXT_MESSAGE_START', + messageId: 'assistant-1', + role: 'assistant', + timestamp: Date.now(), + } as StreamChunk + yield { + type: 'TEXT_MESSAGE_CONTENT', + messageId: 'assistant-1', + delta: REPLY_TEXT, + content: REPLY_TEXT, + timestamp: Date.now(), + } as StreamChunk + yield { + type: 'TEXT_MESSAGE_END', + messageId: 'assistant-1', + timestamp: Date.now(), + } as StreamChunk + yield { + type: 'RUN_FINISHED', + threadId, + runId, + timestamp: Date.now(), + outcome: { type: 'success' }, + } as StreamChunk + })() +} + +function interruptRun( + threadId: string, + runId: string, +): AsyncIterable { + return (async function* () { + yield { + type: 'RUN_STARTED', + threadId, + runId, + timestamp: Date.now(), + } as StreamChunk + yield { + type: 'RUN_FINISHED', + threadId, + runId, + timestamp: Date.now(), + outcome: { + type: 'interrupt', + interrupts: [ + { + id: 'confirm-shipment', + reason: 'confirmation', + message: 'Confirm the shipment?', + responseSchema: confirmSchema, + metadata: { + [INTERRUPT_BINDING_METADATA_KEY]: { + v: INTERRUPT_BINDING_VERSION, + kind: 'generic', + interruptId: 'confirm-shipment', + interruptedRunId: runId, + generation: 0, + responseSchemaHash: digestInterruptJson( + canonicalInterruptJson(confirmSchema), + ), + }, + }, + }, + ], + }, + } as StreamChunk + })() +} + +function stringField(body: unknown, key: string): string | undefined { + if (typeof body !== 'object' || body === null || !(key in body)) { + return undefined + } + const value: unknown = (body as Record)[key] + return typeof value === 'string' ? value : undefined +} + +function scenarioOf(request: Request): 'text' | 'interrupt' { + try { + return new URL(request.url).searchParams.get('scenario') === 'interrupt' + ? 'interrupt' + : 'text' + } catch { + return 'text' + } +} + +export const Route = createFileRoute('/api/persistence-durability')({ + server: { + handlers: { + POST: async ({ request }) => { + const body: unknown = await request.json() + const threadId = stringField(body, 'threadId') ?? 'persistence-thread' + const runId = stringField(body, 'runId') ?? crypto.randomUUID() + const stream = + scenarioOf(request) === 'interrupt' + ? interruptRun(threadId, runId) + : textRun(threadId, runId) + return toServerSentEventsResponse(stream, { + durability: { adapter: memoryStream(request) }, + }) + }, + + // Replay a run from the log so a full reload can re-attach to an in-flight + // run by id (`?offset=-1&runId=…`). Read-only: no producer stream is built. + GET: ({ request }) => { + return resumeServerSentEventsResponse({ + adapter: memoryStream(request), + }) + }, + }, + }, +}) diff --git a/testing/e2e/src/routes/persistence-durability.tsx b/testing/e2e/src/routes/persistence-durability.tsx new file mode 100644 index 000000000..542345ae4 --- /dev/null +++ b/testing/e2e/src/routes/persistence-durability.tsx @@ -0,0 +1,131 @@ +import { useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { localStoragePersistence } from '@tanstack/ai-client' +import type { ChatPersistedState } from '@tanstack/ai-client' + +/** + * Browser-refresh persistence harness (client half). + * + * A `localStoragePersistence` adapter stores ONE combined `{ messages, resume }` + * record per chat id (see `ChatPersistedState`), 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 chat id, so the two scenarios never share a + * storage key. + */ + +// A stable id + threadId per scenario so a reload rehydrates the SAME +// conversation from storage. UIMessage carries a `createdAt` that is not +// JSON-native, so a codec is required; a plain JSON round-trip is fine here +// because the harness only asserts on rendered text + interrupt presence. +const persistence = localStoragePersistence({ + serialize: (value) => JSON.stringify(value), + deserialize: (value) => JSON.parse(value) as ChatPersistedState, +}) + +const textConnection = fetchServerSentEvents('/api/persistence-durability') +const interruptConnection = fetchServerSentEvents( + '/api/persistence-durability?scenario=interrupt', +) + +export const Route = createFileRoute('/persistence-durability')({ + component: PersistenceDurabilityPage, + validateSearch: (search: Record) => ({ + scenario: + search.scenario === 'interrupt' + ? ('interrupt' as const) + : ('text' as const), + }), +}) + +function PersistenceDurabilityPage() { + const { scenario } = Route.useSearch() + const isInterrupt = scenario === 'interrupt' + const chatId = isInterrupt + ? 'persistence-durability-interrupt' + : 'persistence-durability-text' + + const { messages, sendMessage, isLoading, interrupts } = useChat({ + id: chatId, + threadId: chatId, + connection: isInterrupt ? interruptConnection : textConnection, + persistence, + }) + + const [input, setInput] = useState('') + + const handleSubmit = () => { + const text = input.trim() + if (!text) return + setInput('') + void sendMessage(text) + } + + return ( +
+