UNPKG

@tanstack/ai-persistence

Version:

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

886 lines (830 loc) 32.8 kB
import type { ModelMessage, MetadataStore, PersistedArtifactRef, RunStatus, RunStore, Scope, TokenUsage, } from '@tanstack/ai' // Re-export the shared identity type so app code can import Scope from either // `@tanstack/ai` or `@tanstack/ai-persistence`. See {@link Scope} security notes: // pair a client-visible `threadId` with a server-trusted `userId`/`tenantId` // before authorizing load/save (e.g. via `reconstructChat({ authorize })`). export type { MetadataStore, Scope } // =========================================================================== // Store contracts // =========================================================================== // // EVOLUTION POLICY // ---------------- // These store interfaces are the compatibility surface between the core // middleware and every backend — the in-memory reference store and every // adapter an application writes against its own database. // // - Store METHODS are REQUIRED. A new method is a breaking contract change: // every adapter gets a compile error and implements it. Do NOT add methods // as optional-and-feature-detected (`store.method?.(...)`) — an adapter // that has not implemented one is then indistinguishable from one whose // answer is legitimately empty, so the feature silently does nothing in // production instead of failing at build time. `findActiveRun` was optional // for exactly one release cycle and cost us precisely that: reconnect // degraded to "no active run" on every backend that had not caught up. // - Capability tiers belong at the STORE level, not the method level. A // backend that only stores a transcript declares `ChatTranscriptStores` // (no `runs`); it does not declare a half-implemented `RunStore`. // - 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`, `ArtifactRecord`, // `BlobRecord`) speak **epoch milliseconds** (`number`), the native unit for // SQL/`BIGINT` columns and `Date.now()`. Wire/result references that leave the // persistence layer (e.g. core's `PersistedArtifactRef.createdAt`) speak // **ISO-8601 strings**. The middleware performs the number→ISO conversion at // the boundary; do not mix the two on a single field. /** * Durable store for a thread's full message transcript. * * A "thread" is the unit of conversation history. The key is * {@link Scope.threadId} (the same conversation id as * `ChatMiddlewareContext.threadId`). Store methods take a bare string for * adapter simplicity; multi-user isolation is the **host's** job — authorize * against `Scope.userId` / `Scope.tenantId` (derived server-side from session) * before calling load/save, and never treat a client-supplied thread id alone * as an ownership proof (see `Scope` security notes in `@tanstack/ai`). * * `saveThread` always receives and persists the **complete, authoritative** * message list — it is an overwrite, never an append. The middleware snapshots * `ctx.messages` (the full running transcript) into it. */ export interface MessageStore { /** * Return the full stored transcript for `threadId` ({@link Scope.threadId}), * in insertion order. * * INVARIANT: returns an empty array (never `null`/`undefined`) for a thread * that was never saved. Callers treat `[]` as "no history". */ loadThread: (threadId: string) => Promise<Array<ModelMessage>> /** * 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<ModelMessage>) => Promise<void> } // Run lifecycle types live in `@tanstack/ai` and are re-exported here: one run, // one record — shared by this package's `runs` store and `@tanstack/ai-sandbox`'s // run driver, instead of each package keeping a rival definition that can drift. export type { RunStatus, TerminalRunStatus, RunRecord, RunStore, } from '@tanstack/ai' export { isTerminalRunStatus, defineRunStore } from '@tanstack/ai' /** * Lifecycle status of a generation run. Deliberately the same vocabulary as * {@link RunStatus}, so an adapter that stores both kinds of run can share one * status column and one set of checks. */ export type GenerationRunStatus = RunStatus /** * A single generation run (one `generateImage` / `generateVideo` / … call). * * Its primary identity is `runId`: the run/request id the activity mints, the * same AG-UI run id the client sends on the wire. `threadId` is the SLOT the * run fills, a stable app-chosen name that groups successive runs of the same * thing, and it is what a server-driven client hydrates by. Generation state is * kept here, never in the chat {@link RunStore}. * * `result` holds terminal result METADATA (ids, model, urls, a provider video * job id), never the media bytes — those live in a {@link BlobStore}. * `artifacts` are the durable {@link PersistedArtifactRef}s, present only when * byte storage is on. * * @property startedAt - Epoch ms when the run was first created. * @property finishedAt - Epoch ms when the run reached a terminal status. */ export interface GenerationRunRecord { runId: string /** * The scope this run belongs to: a stable, app-chosen name for the slot * successive runs fill (`product-123-hero`, `video-9-start-frame`). * * REQUIRED, per the store-contract rule at the top of this file. * {@link GenerationRunStore.findLatestForThread} is the only query that * hydrates a run, and it keys on this — so a record without one can be * written and then never found again. `withGenerationPersistence` already * refuses to start a run without a scope, and a server-driven client * discards a snapshot that arrives without one, so an optional field here * only described a record no path could produce and no client would accept. */ threadId: string /** `'image' | 'audio' | 'tts' | 'video' | 'transcription'`. */ activity: string provider: string model: string status: GenerationRunStatus startedAt: number finishedAt?: number error?: { message: string; code?: string } /** Terminal result metadata (ids, model, urls). Never the media bytes. */ result?: unknown /** Durable artifact references, when an artifacts + blobs backend is used. */ artifacts?: Array<PersistedArtifactRef> usage?: TokenUsage } /** * Durable store for generation run records, the generation counterpart to * {@link RunStore}. Keyed by its own `runId`, with `threadId` the slot * {@link GenerationRunStore.findLatestForThread} looks runs up by. */ export interface GenerationRunStore { /** * Create a run record, or return the existing one if `runId` is already * present (resume). * * INVARIANT (idempotency): a second call for a `runId` returns the existing * record unchanged; `startedAt`/`activity`/`provider`/`model`/`threadId` are * not mutated. `status` defaults to `'running'` on first creation. */ createOrResume: ( input: Pick< GenerationRunRecord, 'runId' | 'threadId' | 'activity' | 'provider' | 'model' | 'startedAt' > & { status?: GenerationRunStatus }, ) => Promise<GenerationRunRecord> /** * Patch a run record's mutable fields. * * INVARIANT: patching 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< GenerationRunRecord, 'status' | 'finishedAt' | 'error' | 'result' | 'artifacts' | 'usage' > >, ) => Promise<void> /** Return the run record for `runId`, or `null` if none exists. */ get: (runId: string) => Promise<GenerationRunRecord | null> /** * The most recent run linked to `threadId`, or `null`. * * REQUIRED, per the store-contract rule at the top of this file: a * server-authoritative client hydrates by the stable thread id on every * mount, so an adapter without this would be indistinguishable from one that * legitimately has no run — `persistence: true` would silently restore * nothing, forever. `null` is the correct answer only when the thread really * has no runs. The chat parallel is {@link RunStore.findActiveRun}. */ findLatestForThread: (threadId: string) => Promise<GenerationRunRecord | null> } /** 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<string, unknown> response?: unknown } /** A terminal interrupt write for {@link InterruptStore.commitBatch}. */ export type InterruptCommitEntry = | { interruptId: string status: 'resolved' response?: unknown } | { interruptId: string status: 'cancelled' } /** 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<InterruptRecord, 'status' | 'resolvedAt'>, ) => Promise<void> /** * Move an interrupt to `'resolved'`, stamping `resolvedAt` and storing * `response`. A no-op if `interruptId` does not exist. */ resolve: (interruptId: string, response?: unknown) => Promise<void> /** * Move an interrupt to `'cancelled'`, stamping `resolvedAt`. A no-op if * `interruptId` does not exist. */ cancel: (interruptId: string) => Promise<void> /** * Commit terminal writes for a validated resume batch. * * Optional. When present, `withPersistence` calls it once instead of * calling `resolve` and `cancel` for each entry. Apply every entry or none. * * Reject the whole batch (throw, writing nothing) when any entry has a * duplicate `interruptId`, references an `interruptId` that does not exist, * or references an interrupt whose status is not `'pending'`. This is * stricter than `resolve` / `cancel`, which are no-ops for a missing * `interruptId`. */ commitBatch?: (entries: ReadonlyArray<InterruptCommitEntry>) => Promise<void> /** Return the interrupt for `interruptId`, or `null` if none exists. */ get: (interruptId: string) => Promise<InterruptRecord | null> /** * 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<Array<InterruptRecord>> /** Pending interrupts for a thread, ordered by `requestedAt` ascending. */ listPending: (threadId: string) => Promise<Array<InterruptRecord>> /** All interrupts for a run, ordered by `requestedAt` ascending. */ listByRun: (runId: string) => Promise<Array<InterruptRecord>> /** Pending interrupts for a run, ordered by `requestedAt` ascending. */ listPendingByRun: (runId: string) => Promise<Array<InterruptRecord>> } // =========================================================================== // Store typers // =========================================================================== // // Identity helpers that type a store implementation inline: pass an object // literal and get autocomplete + contract checking, with no separate // `: MessageStore` return annotation. They compose into `defineAIPersistence`, // which infers **exact presence** — a store you define becomes a defined, // non-optional, autocompleted key on `persistence.stores`, and accessing a store // you did not define is a compile error. // // ```ts // const persistence = defineAIPersistence({ // stores: { // messages: defineMessageStore({ loadThread, saveThread }), // runs: defineRunStore({ createOrResume, update, get, findActiveRun }), // }, // }) // persistence.stores.runs // RunStore (defined) // persistence.stores.interrupts // compile error — not provided // ``` // // Presence is per STORE, not per method: every method of a store you define is // required (see the evolution policy above). Omitting one is a compile error, // not a partial store. /** Type a {@link MessageStore} implementation inline. */ export function defineMessageStore(store: MessageStore): MessageStore { return store } /** Type an {@link InterruptStore} implementation inline. */ export function defineInterruptStore(store: InterruptStore): InterruptStore { return store } /** Type a {@link MetadataStore} implementation inline. */ export function defineMetadataStore(store: MetadataStore): MetadataStore { return store } /** Type a {@link GenerationRunStore} implementation inline. */ export function defineGenerationRunStore( store: GenerationRunStore, ): GenerationRunStore { return store } /** Type an {@link ArtifactStore} implementation inline. */ export function defineArtifactStore(store: ArtifactStore): ArtifactStore { return store } /** Type a {@link BlobStore} implementation inline. */ export function defineBlobStore(store: BlobStore): BlobStore { return store } /** * Metadata row describing a persisted artifact (generated media, tool output). * * The bytes themselves live in a {@link BlobStore}; this record holds the * descriptive metadata and an optional `sourceUrl` for reference-only * backends. * * @property createdAt - Epoch ms. (Core's wire-facing `PersistedArtifactRef` * exposes the same instant as an ISO string; see the timestamp convention.) */ export interface ArtifactRecord { artifactId: string runId: string threadId: string /** * The blob-store key these bytes actually live under. * * Optional for backwards compatibility: records written before this existed * resolve via the default `artifacts/<runId>/<artifactId>` convention. New * records always carry it, which is what lets `storageKey` put bytes anywhere * — a reader can no longer recompute the path, so it has to be remembered. * Use `resolveArtifactBlobKey(record)` rather than reading it directly. */ blobKey?: string name: string mimeType: string size: number sourceUrl?: string createdAt: number } /** Durable store for artifact metadata records. */ export interface ArtifactStore { /** Insert or overwrite the artifact metadata record. */ save: (record: ArtifactRecord) => Promise<void> /** Return the artifact for `artifactId`, or `null` if none exists. */ get: (artifactId: string) => Promise<ArtifactRecord | null> /** * All artifacts for a run in deterministic snapshot order: `createdAt` * ascending, then `artifactId` ascending by the unsigned UTF-8 bytes of * each string (compare bytes left-to-right; shorter equal prefixes first). * Returns `[]` when the run has none. */ list: (runId: string) => Promise<Array<ArtifactRecord>> /** * All artifacts for a thread in deterministic snapshot order. * Records are ordered by `createdAt` ascending, then by `artifactId` using * the unsigned UTF-8 bytes of each string (compare bytes left-to-right; shorter * equal prefixes first). */ listForThread: (threadId: string) => Promise<Array<ArtifactRecord>> /** * Delete a single artifact by id. A no-op if absent, mirroring * {@link BlobStore.delete} — the two are written and deleted as a pair, so * their contracts match. */ delete: (artifactId: string) => Promise<void> /** * Delete every artifact belonging to `runId`. A no-op when the run has none. * * Required rather than feature-detected: retention and erasure are the point * of storing media durably, and an adapter silently lacking deletion is * indistinguishable from one where there was nothing to delete. */ deleteForRun: (runId: string) => Promise<void> } /** * Accepted body shapes for {@link BlobStore.put}. `ArrayBufferView` already * covers `Uint8Array` and every other typed-array/`DataView`, so no separate * `Uint8Array` member is needed. */ export type BlobBody = | ReadableStream<Uint8Array> | ArrayBuffer | ArrayBufferView | string | Blob /** * Metadata for a stored blob. * * @property size - Byte length, when known. * @property createdAt - Epoch ms first written. * @property updatedAt - Epoch ms last overwritten. */ export interface BlobRecord { key: string size?: number etag?: string contentType?: string customMetadata?: Record<string, string> createdAt?: number updatedAt?: number } /** * A byte range to read, in the shape an HTTP `Range` header resolves to. * * `offset` is measured from the start of the object and must be inside it; * `length` defaults to "everything from `offset` to the end" and is clamped to * the end when it overshoots. Suffix ranges (`bytes=-500`) are the caller's to * resolve against the known size — a serve route has the size on the artifact * record, and has to compare against it anyway to answer `416` before reading. */ export interface BlobRange { offset: number length?: number } /** Options for {@link BlobStore.get}. */ export interface BlobGetOptions { /** * Read only this slice of the object. `body`, `arrayBuffer()` and `text()` * then cover the slice, `size` still reports the WHOLE object, and `range` * reports the slice actually served — the three numbers a `206` response * needs (`Content-Range: bytes <offset>-<offset+length-1>/<size>`). */ range?: BlobRange } /** A stored blob's metadata plus lazy accessors for its bytes. */ export interface BlobObject extends BlobRecord { arrayBuffer: () => Promise<ArrayBuffer> text: () => Promise<string> body?: ReadableStream<Uint8Array> /** * The slice this object exposes, when a {@link BlobGetOptions.range} was * requested and honoured: `offset` as asked, `length` as actually served * (clamped to the end of the object). Absent on a whole-object read. */ range?: { offset: number; length: number } } /** * One page of a {@link BlobStore.list} scan. * * @property cursor - Opaque continuation token; present only when `truncated`. * @property truncated - `true` when more objects match beyond this page. */ export interface BlobListPage { objects: Array<BlobRecord> cursor?: string truncated?: boolean } export interface BlobPutOptions { contentType?: string customMetadata?: Record<string, string> /** * The exact byte length of `body`, when the producer knows it up front. * * Advisory, not a contract the store must honor: it exists so a store can * pick an upload strategy knowingly instead of discovering the length by * buffering. Most useful to an SDK that wants the length as a separate * argument rather than reading it off the stream — S3's `PutObject` * (`ContentLength`) is the archetype — and to a runtime that can re-attach * one (workerd's `FixedLengthStream` ahead of `R2Bucket.put`). * * Only ever set when the length is exact — a wrong value is worse than none, * since runtimes that enforce declared lengths fail the write. Absent means * unknown, and a store must accept a length-less stream regardless: * producers hand one over whenever the origin does not declare a length. */ expectedLength?: number } export interface BlobListOptions { prefix?: string cursor?: string limit?: number } /** Durable object/blob store (byte-storing or reference-only backends). */ export interface BlobStore { /** Insert or overwrite the object at `key`, returning its metadata. */ put: ( key: string, body: BlobBody, options?: BlobPutOptions, ) => Promise<BlobRecord> /** * Return the object at `key` (metadata + byte accessors), or `null`. * * RANGE SEMANTICS: with `options.range`, return only that slice — the bytes * a `206` response carries — and report it back as `range`. `size` still * reports the whole object, so the caller can build `Content-Range` without * a second `head`. The reported `length` is what was actually served: a * requested `length` past the end clamps. An `offset` at or past the end is * a caller error, not a store one — the size is on the artifact record, so a * serve route answers `416` before ever asking the store. * * Range support is part of the contract for any store that holds bytes (the * conformance testkit asserts it): serving a whole file where a slice was * asked for is what makes `<video>` seeking, and Safari playback at all, * fail. A reference-only backend that stores no bytes skips `blobs` * entirely rather than half-implementing it. */ get: (key: string, options?: BlobGetOptions) => Promise<BlobObject | null> /** Return only the metadata for `key`, or `null`. */ head: (key: string) => Promise<BlobRecord | null> /** Remove the object at `key`. A no-op if absent. */ delete: (key: string) => Promise<void> /** * List objects, optionally filtered by `prefix`, in ascending key order. * * CURSOR SEMANTICS: `prefix` matches literally and case-sensitively (SQL * backends must escape LIKE metacharacters, so `run_` matches only the exact * bytes `run_`, not `_` as a wildcard). When `limit` is given and more keys * match, the page is `truncated: true` with a `cursor`; passing that `cursor` * back returns the strictly-following keys (keys `> cursor`). Cursor ordering * is the same byte ordering as the sort, so paging visits every key exactly * once with no gaps or repeats. `limit: 0` yields an empty, untruncated page * with no cursor. */ list: (options?: BlobListOptions) => Promise<BlobListPage> } /** * Sparse bag of **state** store keys — composition / validation only. * * **Not a public product shape.** Prefer the named chat shapes below * ({@link ChatTranscriptStores}, {@link ChatPersistenceStores}, * {@link ChatWithInterruptsStores}). Locks are not included — use * `withLocks` from `@tanstack/ai`. * * @internal Exported from this module for generics; the package root does not * re-export this type — use a named shape or `AIPersistence<{ … }>` instead. */ export interface AIPersistenceStores { messages?: MessageStore runs?: RunStore interrupts?: InterruptStore metadata?: MetadataStore generationRuns?: GenerationRunStore artifacts?: ArtifactStore blobs?: BlobStore } /** * Chat floor: durable transcript. `messages` is required. * * `runs` / `interrupts` / `metadata` remain optional. If `interrupts` is set, * `runs` is required (enforced by `withPersistence` / validators). */ export interface ChatTranscriptStores { messages: MessageStore runs?: RunStore interrupts?: InterruptStore metadata?: MetadataStore } /** * Full chat durability — all four state stores are present. This is what * `memoryPersistence()` returns, and the shape most adapters should declare. * * Backends that only need a transcript should use * {@link ChatTranscriptStores} instead. */ export interface ChatPersistenceStores { messages: MessageStore runs: RunStore interrupts: InterruptStore metadata: MetadataStore } /** * Chat with durable human-in-the-loop interrupts (and optional metadata). * Implies `runs` (interrupt records are run-scoped). * * Prefer {@link ChatPersistenceStores} when you also have metadata (packaged * backends). Use this when interrupts are required but metadata is not. */ export interface ChatWithInterruptsStores { messages: MessageStore runs: RunStore interrupts: InterruptStore metadata?: MetadataStore } /** * Persistence aggregate. Parameterize with a named store shape, or a sparse * map for composition (`defineAIPersistence` / `composePersistence`). * * Default is the sparse bag so untyped / dynamic bags still type-check; * prefer {@link ChatTranscriptPersistence} or {@link ChatPersistence} at * call sites. */ export interface AIPersistence< TStores extends AIPersistenceStores = AIPersistenceStores, > { stores: ExactStoreKeys<TStores> } /** {@link AIPersistence} for {@link ChatTranscriptStores}. */ export type ChatTranscriptPersistence = AIPersistence<ChatTranscriptStores> /** {@link AIPersistence} for {@link ChatPersistenceStores}. */ export type ChatPersistence = AIPersistence<ChatPersistenceStores> /** {@link AIPersistence} for {@link ChatWithInterruptsStores}. */ export type ChatWithInterruptsPersistence = AIPersistence<ChatWithInterruptsStores> type StoreKey = keyof AIPersistenceStores type ExactStoreKeys<TStores> = Exclude<keyof TStores, StoreKey> extends never ? TStores : TStores & Record<Exclude<keyof TStores, StoreKey>, 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<OverrideStoreValue<TOverrides, TKey>, false | undefined> | (undefined extends OverrideStoreValue<TOverrides, TKey> ? Exclude<BaseStoreValue<TBase, TKey>, undefined> : never) : Exclude<BaseStoreValue<TBase, TKey>, undefined> type BaseStoreIsRequired< TBase extends AIPersistenceStores, TKey extends StoreKey, > = TKey extends keyof TBase ? object extends Pick<TBase, TKey> ? false : true : false type ResolvedStoreIsRequired< TBase extends AIPersistenceStores, TOverrides extends AIPersistenceOverrides, TKey extends StoreKey, > = TKey extends keyof TOverrides ? false extends OverrideStoreValue<TOverrides, TKey> ? false : undefined extends OverrideStoreValue<TOverrides, TKey> ? BaseStoreIsRequired<TBase, TKey> : true : BaseStoreIsRequired<TBase, TKey> type ResolvedRequiredKeys< TBase extends AIPersistenceStores, TOverrides extends AIPersistenceOverrides, > = { [TKey in StoreKey]-?: [ResolvedStoreValue<TBase, TOverrides, TKey>] extends [ never, ] ? never : ResolvedStoreIsRequired<TBase, TOverrides, TKey> extends true ? TKey : never }[StoreKey] type ResolvedOptionalKeys< TBase extends AIPersistenceStores, TOverrides extends AIPersistenceOverrides, > = { [TKey in StoreKey]-?: [ResolvedStoreValue<TBase, TOverrides, TKey>] extends [ never, ] ? never : ResolvedStoreIsRequired<TBase, TOverrides, TKey> extends true ? never : TKey }[StoreKey] type Simplify<T> = { [TKey in keyof T]: T[TKey] } export type ComposedAIPersistenceStores< TBase extends AIPersistenceStores, TOverrides extends AIPersistenceOverrides, > = Simplify< { [TKey in ResolvedRequiredKeys<TBase, TOverrides>]: ResolvedStoreValue< TBase, TOverrides, TKey > } & { [TKey in ResolvedOptionalKeys<TBase, TOverrides>]?: ResolvedStoreValue< TBase, TOverrides, TKey > } > const storeKeys = [ 'messages', 'runs', 'generationRuns', 'interrupts', 'metadata', 'artifacts', 'blobs', ] satisfies Array<StoreKey> const storeKeySet = new Set<string>(storeKeys) function assertKnownStoreKeys(stores: object, location: string): void { for (const key of Object.keys(stores)) { if (!storeKeySet.has(key)) { throw new Error(`Unknown AIPersistence ${location} key: ${key}`) } } } export function validatePersistenceStoreKeys(persistence: AIPersistence): void { assertKnownStoreKeys(persistence.stores, 'store') } /** * Chat middleware entrypoint rules: * - `messages` is required (chat persistence means a durable transcript) * - `interrupts` requires `runs` (interrupt records are run-scoped) */ export function validateChatPersistenceStores( persistence: AIPersistence, ): void { validatePersistenceStoreKeys(persistence) if (!persistence.stores.messages) { throw new Error('Chat persistence requires stores.messages.') } if (persistence.stores.interrupts && !persistence.stores.runs) { throw new Error('Chat persistence stores.interrupts requires stores.runs.') } } /** * Generation middleware entrypoint rule: `generationRuns` is required (the * generation run lifecycle is keyed on its own `runId`, not a chat conversation * `threadId`). When artifact persistence is used, `artifacts` and `blobs` must * be provided together. */ export function validateGenerationPersistenceStores( persistence: AIPersistence, ): void { validatePersistenceStoreKeys(persistence) const hasArtifacts = persistence.stores.artifacts !== undefined const hasBlobs = persistence.stores.blobs !== undefined if (hasArtifacts !== hasBlobs) { throw new Error( 'Generation artifact persistence requires both stores.artifacts and stores.blobs.', ) } if (!persistence.stores.generationRuns) { throw new Error('Generation persistence requires stores.generationRuns.') } } /** * Server hydrate entrypoint rule: `messages` is required. */ export function validateReconstructChatStores( persistence: AIPersistence, ): void { validatePersistenceStoreKeys(persistence) if (!persistence.stores.messages) { throw new Error('reconstructChat requires stores.messages.') } } /** * Server hydrate entrypoint rule for generation: `generationRuns` is required. * The run store resolves the latest generation for a thread (or a specific run * id), so a server-authoritative client can hydrate the last generation's * status, result, and artifact refs on load. */ export function validateReconstructGenerationStores( persistence: AIPersistence, ): void { validatePersistenceStoreKeys(persistence) if (!persistence.stores.generationRuns) { throw new Error('reconstructGeneration requires stores.generationRuns.') } } export function defineAIPersistence<TStores extends AIPersistenceStores>( persistence: AIPersistence<ExactStoreKeys<TStores>>, ): AIPersistence<TStores> { validatePersistenceStoreKeys(persistence) return persistence } export function composePersistence< TBase extends AIPersistenceStores, TOverrides extends AIPersistenceOverrides, >( base: AIPersistence<TBase>, config: { overrides: ExactStoreKeys<TOverrides> }, ): AIPersistence<ComposedAIPersistenceStores<TBase, TOverrides>> 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<TKey extends StoreKey>( stores: AIPersistenceStores, key: TKey, value: NonNullable<AIPersistenceStores[TKey]>, ): void { stores[key] = value }