UNPKG

@tanstack/ai-persistence

Version:

Composable state persistence for TanStack AI messages, runs, interrupts, metadata, and locks.

253 lines (252 loc) 13.2 kB
import { ChatMiddleware, GenerationMiddleware, PersistedArtifactActivity, PersistedArtifactRef, PersistedArtifactRole, TokenUsage } from '@tanstack/ai'; import { AIPersistence, AIPersistenceStores, BlobBody, ChatTranscriptStores, RunStore } from './types.js'; /** * How generated media is turned into durable artifacts: which pieces of a * result become artifacts, what they are named, where their bytes land, and how * the bytes are fetched when the provider returns a URL rather than inline data. * * Consumed by {@link withGenerationPersistence} through * {@link WithGenerationPersistenceOptions}. Chat persistence has no artifacts — * its options are {@link WithPersistenceOptions}. */ export interface ArtifactPersistenceOptions { extractArtifacts?: (input: GenerationArtifactExtractionInput) => Array<GenerationArtifactDescriptor | PersistedArtifactRef> | Promise<Array<GenerationArtifactDescriptor | PersistedArtifactRef>>; nameArtifact?: (input: GenerationArtifactNameInput) => string; /** * Map a freshly-persisted artifact ref to the durable app-origin URL that * serves its bytes (your `GET` route around `retrieveArtifact` / * `retrieveBlob`). The returned URL is stamped onto `ref.url` and written into * the result's media field, so both the live and the restored result render * durable media from your own origin instead of the provider's expiring link. * Return `undefined` to leave a ref without a durable URL. */ artifactUrl?: (ref: PersistedArtifactRef) => string | undefined; /** * Choose the blob-store key each artifact's bytes are written under, so * generated media can land in your own folder structure rather than the * default `artifacts/<runId>/<artifactId>`. * * ```ts * storageKey: ({ runId, artifactId, mimeType }) => * `video/${videoId}/frames/${runId}-${artifactId}.png` * ``` * * Server-side only, and deliberately so: a key supplied by the browser would * be a path-traversal and cross-tenant-write vector. * * The resolved key is recorded on `ArtifactRecord.blobKey`, because once the * path is arbitrary a reader can no longer recompute it. Returning a * non-unique key overwrites — include `artifactId` (or something equally * unique) unless you intend that. */ storageKey?: (input: { artifactId: string; runId: string; threadId: string; role: PersistedArtifactRole; activity: PersistedArtifactActivity; path: string; mimeType: string; name: string; }) => string; /** * Opt in to fetching prompt media referenced by URL (`role: 'input'`). * * Off by default, and deliberately expressed as a predicate rather than a * boolean: input URLs come from the caller, so fetching them server-side * turns your server into a proxy for whatever the caller names — cloud * metadata endpoints, `localhost` admin services, anything your network can * reach. The bytes are also redundant in the common case, since the client * already had the media it referenced. * * Enable this only when you genuinely need a durable copy of caller-supplied * media (a "paste an image URL" input box, say), and validate the target: * * ```ts * allowInputUrl: ({ url }) => url.hostname.endsWith('.cdn.example.com') * ``` * * Requests are additionally forced through the same baseline checks every * artifact fetch gets (http/https only, timeout, size cap), plus — because * the target is untrusted — a loopback/private/link-local host block and * `redirect: 'manual'` so a 302 cannot hop to an internal address. Those are * a backstop, not a substitute for a narrow predicate: a hostname that * resolves to a private address still passes a literal-IP check. */ allowInputUrl?: (input: { url: URL; descriptor: GenerationArtifactDescriptor; }) => boolean | Promise<boolean>; /** Abort an artifact fetch after this many ms. Default 30_000. */ artifactFetchTimeoutMs?: number; /** * Refuse an artifact body larger than this many bytes. Default 1 GiB. * * This is a bound on TRANSFER, not on memory: the URL path streams into the * blob store and never buffers, so a 1 GiB artifact costs a streaming store * (R2, S3, filesystem) flat memory. What the cap buys is a ceiling on what a * broken or hostile origin can make you pull and store — `content-length` is * advisory, so without it an artifact fetch is an unbounded transfer billed * to you. * * Pass `false` to remove the ceiling entirely. That also removes the * cap-enforcing `TransformStream` wrapper, so the fetched body reaches your * store exactly as `fetch` produced it — on workerd that means it keeps its * native declared length and `R2Bucket.put` can single-shot it with no hint, * no multipart, and nothing buffered. Do that when you trust the origins you * fetch from (your provider's CDN); keep the cap when `allowInputUrl` lets * callers name the URL. */ maxArtifactBytes?: number | false; /** * `fetch` used to download artifact bytes. Defaults to the global. Inject to * route downloads through a proxy or an egress-restricted agent — the most * robust SSRF control available here, since it can resolve and check the * address actually connected to. */ artifactFetch?: typeof globalThis.fetch; } /** * Options for {@link withGenerationPersistence}: everything in * {@link ArtifactPersistenceOptions}, plus an optional scope override. */ export interface WithGenerationPersistenceOptions extends ArtifactPersistenceOptions { /** * Override the scope runs are filed under. Defaults to the `threadId` you * passed the activity, which is normally what you want, so leave this unset * unless the record belongs somewhere other than the activity's own scope. */ threadId?: string; } export interface GenerationArtifactDescriptor { role: PersistedArtifactRole; path: string; mediaType?: PersistedArtifactRef['source']['mediaType']; mimeType?: string; bytes?: BlobBody; url?: string; json?: unknown; name?: string; jobId?: string; expiresAt?: string | Date; } export interface GenerationArtifactExtractionInput { activity: PersistedArtifactActivity; provider: string; model: string; threadId: string; runId: string; inputs: unknown; result: unknown; } export interface GenerationArtifactNameInput { descriptor: GenerationArtifactDescriptor; activity: PersistedArtifactActivity; provider: string; model: string; threadId: string; runId: string; index: number; } type StoreIsDefinitelyPresent<TStores extends AIPersistenceStores, TKey extends keyof AIPersistenceStores> = TKey extends keyof TStores ? object extends Pick<TStores, TKey> ? false : [Exclude<TStores[TKey], undefined>] extends [never] ? false : true : false; type StoreIsDefinitelyAbsent<TStores extends AIPersistenceStores, TKey extends keyof AIPersistenceStores> = TKey extends keyof TStores ? [Exclude<TStores[TKey], undefined>] extends [never] ? true : false : true; /** * Chat entrypoint invalid when: * - `messages` is known-absent, or * - `interrupts` is known-present without `runs`. * * Fully optional bags (`AIPersistence` with all `?` keys) stay assignable and * are checked at runtime by {@link validateChatPersistenceStores}. */ type InvalidChatPersistence<TStores extends AIPersistenceStores> = StoreIsDefinitelyAbsent<TStores, 'messages'> extends true ? true : StoreIsDefinitelyPresent<TStores, 'interrupts'> extends true ? StoreIsDefinitelyAbsent<TStores, 'runs'> : false; /** * Generation entrypoint invalid when `generationRuns` is known-absent, or when * exactly one of `artifacts` / `blobs` is present (artifact persistence needs * both). */ type InvalidGenerationPersistence<TStores extends AIPersistenceStores> = StoreIsDefinitelyAbsent<TStores, 'generationRuns'> extends true ? true : StoreIsDefinitelyPresent<TStores, 'artifacts'> extends true ? StoreIsDefinitelyAbsent<TStores, 'blobs'> : StoreIsDefinitelyPresent<TStores, 'blobs'> extends true ? StoreIsDefinitelyAbsent<TStores, 'artifacts'> : false; type ValidChatPersistence<TStores extends AIPersistenceStores> = InvalidChatPersistence<TStores> extends true ? never : unknown; type ValidGenerationPersistence<TStores extends AIPersistenceStores> = InvalidGenerationPersistence<TStores> extends true ? never : unknown; /** * Record a human-in-the-loop PAUSE. * * Deliberately writes NO `finishedAt`: `'interrupted'` is not a terminal status * (`isTerminalRunStatus('interrupted')` is `false`), and stamping a terminal * timestamp on it told every reader the run was over while it was in fact * waiting for a human. Only `abortRun`/`completeRun`/`failRun` finish a run. */ export declare function interruptRun(runs: RunStore | undefined, runId: string, usage?: TokenUsage): Promise<void>; /** * Record that the run has ended for good — an explicit cancel, or a disconnect * on a run that has nothing to reattach to. Terminal, so it carries * `finishedAt`. */ export declare function abortRun(runs: RunStore | undefined, runId: string, usage?: TokenUsage): Promise<void>; /** * Chat-only **state** persistence middleware. Provides durable transcript, * run records, and interrupts for `chat()`. Does **not** provide locks — * use `withLocks` from `@tanstack/ai` for multi-instance coordination. * * This middleware never mutates the chunk stream; delivery durability * (replaying a disconnected/reloaded stream) is a separate transport-layer * concern (see the resumable-streams docs). * * Requires `stores.messages`. When `stores.interrupts` is present, * `stores.runs` is also required. * * ⚠️ AUTHORITATIVE-HISTORY CONTRACT: when a request carries a non-empty * `messages` array it is treated as the FULL conversation history and, on * finish, **overwrites** the entire stored thread. Post only the complete * transcript, never a delta — sending just the newest message(s) will replace * (and thereby destroy) the stored thread. To continue a stored thread without * resending history, pass an empty `messages` array and the stored transcript * is loaded and used. */ export interface WithPersistenceOptions { /** * Also persist a throttled snapshot of the in-progress assistant reply while * it streams. Off by default — the transcript is otherwise persisted at the * pending turn (`onStart`), interrupt boundaries, and completion (`onFinish`). * Enable it to recover partial output if the process dies mid-generation, at * the cost of extra writes. Snapshots are throttled to at most one per * {@link WithPersistenceOptions.snapshotIntervalMs}. */ snapshotStreaming?: boolean; /** * Minimum milliseconds between streaming snapshots when `snapshotStreaming` * is on. Defaults to 1000. */ snapshotIntervalMs?: number; } /** * @param persistence - Must satisfy {@link ChatTranscriptStores} (messages * required). Known-absent `messages` or `interrupts` without `runs` fail at * compile time; fully dynamic bags are checked at runtime. */ export declare function withPersistence<TStores extends ChatTranscriptStores>(persistence: AIPersistence<TStores> & ValidChatPersistence<TStores>, options?: WithPersistenceOptions): ChatMiddleware; /** * Generation-only persistence middleware. Tracks generation run status (run * records keyed by `runId`) and, when `stores.artifacts` + `stores.blobs` are * both provided, persists the generated media for image, audio, TTS, video, and * transcription activities. * * Requires `stores.generationRuns`. A generation activity has no conversation, * so the run is keyed on its own `runId` (`ctx.runId ?? ctx.requestId`), which * is never faked from anything else. * * A `threadId` is REQUIRED alongside it — not as a link to a chat, but as the * stable app-chosen slot successive runs of the same thing fill * (`product-123-hero`, `video-9-start-frame`). It is what * `stores.generationRuns.findLatestForThread` keys on, and therefore the only * way a run is ever hydrated again. It comes from the `threadId` passed to the * activity, or from {@link WithGenerationPersistenceOptions.threadId} when that * overrides it; supplying neither throws at `onStart` rather than filing a run * nothing can find. * * On success the terminal result metadata (ids, urls — never media bytes) and, * when artifact persistence is on, the persisted artifact refs are captured onto * the run record so a server-authoritative client can hydrate the last * generation for a thread via {@link reconstructGeneration}. */ export declare function withGenerationPersistence<TStores extends AIPersistenceStores>(persistence: AIPersistence<TStores> & ValidGenerationPersistence<TStores>, opts?: WithGenerationPersistenceOptions): GenerationMiddleware; export {};