@tanstack/ai-persistence 0.5.7 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,25 @@
1
1
  import { ModelMessage, MetadataStore, PersistedArtifactRef, RunStatus, RunStore, Scope, TokenUsage } from '@tanstack/ai';
2
2
  export type { MetadataStore, Scope };
3
+ /**
4
+ * One page of a thread from {@link MessageStore.loadThread} when the caller
5
+ * passed a paging hint.
6
+ *
7
+ * Middleware omits the hint and always gets a full `Array<ModelMessage>`,
8
+ * never this shape.
9
+ *
10
+ * `truncated: true` requires `cursor`. Without a cursor the client cannot
11
+ * request the next older window, so `reconstructChat` treats that page as
12
+ * complete.
13
+ */
14
+ export type MessagePage = {
15
+ messages: Array<ModelMessage>;
16
+ truncated: false;
17
+ cursor?: never;
18
+ } | {
19
+ messages: Array<ModelMessage>;
20
+ truncated: true;
21
+ cursor: string;
22
+ };
3
23
  /**
4
24
  * Durable store for a thread's full message transcript.
5
25
  *
@@ -17,13 +37,27 @@ export type { MetadataStore, Scope };
17
37
  */
18
38
  export interface MessageStore {
19
39
  /**
20
- * Return the full stored transcript for `threadId` ({@link Scope.threadId}),
40
+ * Return the stored transcript for `threadId` ({@link Scope.threadId}),
21
41
  * in insertion order.
22
42
  *
43
+ * Call with only `threadId` (middleware, `onStart`, `onFinish`) and this
44
+ * MUST return the full transcript as an `Array<ModelMessage>`. Never a
45
+ * {@link MessagePage}.
46
+ *
47
+ * `options.limit` and `options.before` are an optional paging hint for
48
+ * hydrate. Adapters may ignore them and still return the full array. An
49
+ * adapter that pages returns a {@link MessagePage}.
50
+ *
23
51
  * INVARIANT: returns an empty array (never `null`/`undefined`) for a thread
24
52
  * that was never saved. Callers treat `[]` as "no history".
25
53
  */
26
- loadThread: (threadId: string) => Promise<Array<ModelMessage>>;
54
+ loadThread: {
55
+ (threadId: string): Promise<Array<ModelMessage>>;
56
+ (threadId: string, options: {
57
+ limit?: number;
58
+ before?: string;
59
+ }): Promise<Array<ModelMessage> | MessagePage>;
60
+ };
27
61
  /**
28
62
  * Overwrite the stored transcript for `threadId` with `messages`.
29
63
  *
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","names":[],"sources":["../../src/types.ts"],"sourcesContent":["import type {\n ModelMessage,\n MetadataStore,\n PersistedArtifactRef,\n RunStatus,\n RunStore,\n Scope,\n TokenUsage,\n} from '@tanstack/ai'\n\n// Re-export the shared identity type so app code can import Scope from either\n// `@tanstack/ai` or `@tanstack/ai-persistence`. See {@link Scope} security notes:\n// pair a client-visible `threadId` with a server-trusted `userId`/`tenantId`\n// before authorizing load/save (e.g. via `reconstructChat({ authorize })`).\nexport type { MetadataStore, Scope }\n\n// ===========================================================================\n// Store contracts\n// ===========================================================================\n//\n// EVOLUTION POLICY\n// ----------------\n// These store interfaces are the compatibility surface between the core\n// middleware and every backend — the in-memory reference store and every\n// adapter an application writes against its own database.\n//\n// - Store METHODS are REQUIRED. A new method is a breaking contract change:\n// every adapter gets a compile error and implements it. Do NOT add methods\n// as optional-and-feature-detected (`store.method?.(...)`) — an adapter\n// that has not implemented one is then indistinguishable from one whose\n// answer is legitimately empty, so the feature silently does nothing in\n// production instead of failing at build time. `findActiveRun` was optional\n// for exactly one release cycle and cost us precisely that: reconnect\n// degraded to \"no active run\" on every backend that had not caught up.\n// - Capability tiers belong at the STORE level, not the method level. A\n// backend that only stores a transcript declares `ChatTranscriptStores`\n// (no `runs`); it does not declare a half-implemented `RunStore`.\n// - Never tighten an existing method's required arguments or widen its\n// required return shape in a breaking way.\n//\n// The shared conformance testkit (`./testkit/conformance.ts`) is the\n// authoritative compatibility gate: every invariant documented on the methods\n// below is asserted there, and every backend runs the identical suite. If an\n// invariant is not encoded in the testkit, adapters cannot discover it — so\n// promote new invariants into both the JSDoc here AND the testkit.\n//\n// TIMESTAMP CONVENTION\n// --------------------\n// Store *records* (`RunRecord`, `InterruptRecord`, `ArtifactRecord`,\n// `BlobRecord`) speak **epoch milliseconds** (`number`), the native unit for\n// SQL/`BIGINT` columns and `Date.now()`. Wire/result references that leave the\n// persistence layer (e.g. core's `PersistedArtifactRef.createdAt`) speak\n// **ISO-8601 strings**. The middleware performs the number→ISO conversion at\n// the boundary; do not mix the two on a single field.\n\n/**\n * Durable store for a thread's full message transcript.\n *\n * A \"thread\" is the unit of conversation history. The key is\n * {@link Scope.threadId} (the same conversation id as\n * `ChatMiddlewareContext.threadId`). Store methods take a bare string for\n * adapter simplicity; multi-user isolation is the **host's** job — authorize\n * against `Scope.userId` / `Scope.tenantId` (derived server-side from session)\n * before calling load/save, and never treat a client-supplied thread id alone\n * as an ownership proof (see `Scope` security notes in `@tanstack/ai`).\n *\n * `saveThread` always receives and persists the **complete, authoritative**\n * message list — it is an overwrite, never an append. The middleware snapshots\n * `ctx.messages` (the full running transcript) into it.\n */\nexport interface MessageStore {\n /**\n * Return the full stored transcript for `threadId` ({@link Scope.threadId}),\n * in insertion order.\n *\n * INVARIANT: returns an empty array (never `null`/`undefined`) for a thread\n * that was never saved. Callers treat `[]` as \"no history\".\n */\n loadThread: (threadId: string) => Promise<Array<ModelMessage>>\n /**\n * Overwrite the stored transcript for `threadId` with `messages`.\n *\n * INVARIANT: this is a full replace. `messages` is the complete authoritative\n * history; the previous contents are discarded (not merged or appended).\n */\n saveThread: (threadId: string, messages: Array<ModelMessage>) => Promise<void>\n}\n\n// Run lifecycle types live in `@tanstack/ai` and are re-exported here: one run,\n// one record — shared by this package's `runs` store and `@tanstack/ai-sandbox`'s\n// run driver, instead of each package keeping a rival definition that can drift.\nexport type {\n RunStatus,\n TerminalRunStatus,\n RunRecord,\n RunStore,\n} from '@tanstack/ai'\nexport { isTerminalRunStatus, defineRunStore } from '@tanstack/ai'\n\n/**\n * Lifecycle status of a generation run. Deliberately the same vocabulary as\n * {@link RunStatus}, so an adapter that stores both kinds of run can share one\n * status column and one set of checks.\n */\nexport type GenerationRunStatus = RunStatus\n\n/**\n * A single generation run (one `generateImage` / `generateVideo` / … call).\n *\n * Its primary identity is `runId`: the run/request id the activity mints, the\n * same AG-UI run id the client sends on the wire. `threadId` is the SLOT the\n * run fills, a stable app-chosen name that groups successive runs of the same\n * thing, and it is what a server-driven client hydrates by. Generation state is\n * kept here, never in the chat {@link RunStore}.\n *\n * `result` holds terminal result METADATA (ids, model, urls, a provider video\n * job id), never the media bytes — those live in a {@link BlobStore}.\n * `artifacts` are the durable {@link PersistedArtifactRef}s, present only when\n * byte storage is on.\n *\n * @property startedAt - Epoch ms when the run was first created.\n * @property finishedAt - Epoch ms when the run reached a terminal status.\n */\nexport interface GenerationRunRecord {\n runId: string\n /**\n * The scope this run belongs to: a stable, app-chosen name for the slot\n * successive runs fill (`product-123-hero`, `video-9-start-frame`).\n *\n * REQUIRED, per the store-contract rule at the top of this file.\n * {@link GenerationRunStore.findLatestForThread} is the only query that\n * hydrates a run, and it keys on this — so a record without one can be\n * written and then never found again. `withGenerationPersistence` already\n * refuses to start a run without a scope, and a server-driven client\n * discards a snapshot that arrives without one, so an optional field here\n * only described a record no path could produce and no client would accept.\n */\n threadId: string\n /** `'image' | 'audio' | 'tts' | 'video' | 'transcription'`. */\n activity: string\n provider: string\n model: string\n status: GenerationRunStatus\n startedAt: number\n finishedAt?: number\n error?: { message: string; code?: string }\n /** Terminal result metadata (ids, model, urls). Never the media bytes. */\n result?: unknown\n /** Durable artifact references, when an artifacts + blobs backend is used. */\n artifacts?: Array<PersistedArtifactRef>\n usage?: TokenUsage\n}\n\n/**\n * Durable store for generation run records, the generation counterpart to\n * {@link RunStore}. Keyed by its own `runId`, with `threadId` the slot\n * {@link GenerationRunStore.findLatestForThread} looks runs up by.\n */\nexport interface GenerationRunStore {\n /**\n * Create a run record, or return the existing one if `runId` is already\n * present (resume).\n *\n * INVARIANT (idempotency): a second call for a `runId` returns the existing\n * record unchanged; `startedAt`/`activity`/`provider`/`model`/`threadId` are\n * not mutated. `status` defaults to `'running'` on first creation.\n */\n createOrResume: (\n input: Pick<\n GenerationRunRecord,\n 'runId' | 'threadId' | 'activity' | 'provider' | 'model' | 'startedAt'\n > & { status?: GenerationRunStatus },\n ) => Promise<GenerationRunRecord>\n /**\n * Patch a run record's mutable fields.\n *\n * INVARIANT: patching a `runId` that does not exist is a **no-op** — it must\n * not throw and must not create a record.\n */\n update: (\n runId: string,\n patch: Partial<\n Pick<\n GenerationRunRecord,\n 'status' | 'finishedAt' | 'error' | 'result' | 'artifacts' | 'usage'\n >\n >,\n ) => Promise<void>\n /** Return the run record for `runId`, or `null` if none exists. */\n get: (runId: string) => Promise<GenerationRunRecord | null>\n /**\n * The most recent run linked to `threadId`, or `null`.\n *\n * REQUIRED, per the store-contract rule at the top of this file: a\n * server-authoritative client hydrates by the stable thread id on every\n * mount, so an adapter without this would be indistinguishable from one that\n * legitimately has no run — `persistence: true` would silently restore\n * nothing, forever. `null` is the correct answer only when the thread really\n * has no runs. The chat parallel is {@link RunStore.findActiveRun}.\n */\n findLatestForThread: (threadId: string) => Promise<GenerationRunRecord | null>\n}\n\n/** Lifecycle status of a human-in-the-loop interrupt. */\nexport type InterruptStatus = 'pending' | 'resolved' | 'cancelled'\n\n/**\n * A human-in-the-loop interrupt (tool approval, client-tool input request, …).\n *\n * @property requestedAt - Epoch ms when the interrupt was created.\n * @property resolvedAt - Epoch ms when the interrupt was resolved/cancelled;\n * absent while pending.\n */\nexport interface InterruptRecord {\n interruptId: string\n runId: string\n threadId: string\n status: InterruptStatus\n requestedAt: number\n resolvedAt?: number\n payload: Record<string, unknown>\n response?: unknown\n}\n\n/** A terminal interrupt write for {@link InterruptStore.commitBatch}. */\nexport type InterruptCommitEntry =\n | {\n interruptId: string\n status: 'resolved'\n response?: unknown\n }\n | {\n interruptId: string\n status: 'cancelled'\n }\n\n/** Durable store for human-in-the-loop interrupts. */\nexport interface InterruptStore {\n /**\n * Persist a new interrupt in the `'pending'` state.\n *\n * The record is accepted without `status`/`resolvedAt` so a \"born resolved\"\n * interrupt is unrepresentable — every interrupt begins pending and only\n * `resolve`/`cancel` may move it to a terminal state.\n *\n * INVARIANT (insert-if-absent): if an interrupt with the same `interruptId`\n * already exists, `create` is a **no-op** — it must NOT overwrite the\n * existing record. This is the canonical behaviour (SQL backends implement it\n * via `ON CONFLICT DO NOTHING` / upsert-with-empty-update), so a duplicate\n * create can never clobber a resolved interrupt back to pending.\n */\n create: (\n record: Omit<InterruptRecord, 'status' | 'resolvedAt'>,\n ) => Promise<void>\n /**\n * Move an interrupt to `'resolved'`, stamping `resolvedAt` and storing\n * `response`. A no-op if `interruptId` does not exist.\n */\n resolve: (interruptId: string, response?: unknown) => Promise<void>\n /**\n * Move an interrupt to `'cancelled'`, stamping `resolvedAt`. A no-op if\n * `interruptId` does not exist.\n */\n cancel: (interruptId: string) => Promise<void>\n /**\n * Commit terminal writes for a validated resume batch.\n *\n * Optional. When present, `withPersistence` calls it once instead of\n * calling `resolve` and `cancel` for each entry. Apply every entry or none.\n *\n * Reject the whole batch (throw, writing nothing) when any entry has a\n * duplicate `interruptId`, references an `interruptId` that does not exist,\n * or references an interrupt whose status is not `'pending'`. This is\n * stricter than `resolve` / `cancel`, which are no-ops for a missing\n * `interruptId`.\n */\n commitBatch?: (entries: ReadonlyArray<InterruptCommitEntry>) => Promise<void>\n /** Return the interrupt for `interruptId`, or `null` if none exists. */\n get: (interruptId: string) => Promise<InterruptRecord | null>\n /**\n * All interrupts for a thread.\n *\n * INVARIANT: ordered by insertion (equivalently `requestedAt` ascending). SQL\n * backends MUST `ORDER BY requested_at` — the middleware and testkit rely on\n * this stable ordering.\n */\n list: (threadId: string) => Promise<Array<InterruptRecord>>\n /** Pending interrupts for a thread, ordered by `requestedAt` ascending. */\n listPending: (threadId: string) => Promise<Array<InterruptRecord>>\n /** All interrupts for a run, ordered by `requestedAt` ascending. */\n listByRun: (runId: string) => Promise<Array<InterruptRecord>>\n /** Pending interrupts for a run, ordered by `requestedAt` ascending. */\n listPendingByRun: (runId: string) => Promise<Array<InterruptRecord>>\n}\n\n// ===========================================================================\n// Store typers\n// ===========================================================================\n//\n// Identity helpers that type a store implementation inline: pass an object\n// literal and get autocomplete + contract checking, with no separate\n// `: MessageStore` return annotation. They compose into `defineAIPersistence`,\n// which infers **exact presence** — a store you define becomes a defined,\n// non-optional, autocompleted key on `persistence.stores`, and accessing a store\n// you did not define is a compile error.\n//\n// ```ts\n// const persistence = defineAIPersistence({\n// stores: {\n// messages: defineMessageStore({ loadThread, saveThread }),\n// runs: defineRunStore({ createOrResume, update, get, findActiveRun }),\n// },\n// })\n// persistence.stores.runs // RunStore (defined)\n// persistence.stores.interrupts // compile error — not provided\n// ```\n//\n// Presence is per STORE, not per method: every method of a store you define is\n// required (see the evolution policy above). Omitting one is a compile error,\n// not a partial store.\n\n/** Type a {@link MessageStore} implementation inline. */\nexport function defineMessageStore(store: MessageStore): MessageStore {\n return store\n}\n/** Type an {@link InterruptStore} implementation inline. */\nexport function defineInterruptStore(store: InterruptStore): InterruptStore {\n return store\n}\n/** Type a {@link MetadataStore} implementation inline. */\nexport function defineMetadataStore(store: MetadataStore): MetadataStore {\n return store\n}\n/** Type a {@link GenerationRunStore} implementation inline. */\nexport function defineGenerationRunStore(\n store: GenerationRunStore,\n): GenerationRunStore {\n return store\n}\n/** Type an {@link ArtifactStore} implementation inline. */\nexport function defineArtifactStore(store: ArtifactStore): ArtifactStore {\n return store\n}\n/** Type a {@link BlobStore} implementation inline. */\nexport function defineBlobStore(store: BlobStore): BlobStore {\n return store\n}\n\n/**\n * Metadata row describing a persisted artifact (generated media, tool output).\n *\n * The bytes themselves live in a {@link BlobStore}; this record holds the\n * descriptive metadata and an optional `sourceUrl` for reference-only\n * backends.\n *\n * @property createdAt - Epoch ms. (Core's wire-facing `PersistedArtifactRef`\n * exposes the same instant as an ISO string; see the timestamp convention.)\n */\nexport interface ArtifactRecord {\n artifactId: string\n runId: string\n threadId: string\n /**\n * The blob-store key these bytes actually live under.\n *\n * Optional for backwards compatibility: records written before this existed\n * resolve via the default `artifacts/<runId>/<artifactId>` convention. New\n * records always carry it, which is what lets `storageKey` put bytes anywhere\n * — a reader can no longer recompute the path, so it has to be remembered.\n * Use `resolveArtifactBlobKey(record)` rather than reading it directly.\n */\n blobKey?: string\n name: string\n mimeType: string\n size: number\n sourceUrl?: string\n createdAt: number\n}\n\n/** Durable store for artifact metadata records. */\nexport interface ArtifactStore {\n /** Insert or overwrite the artifact metadata record. */\n save: (record: ArtifactRecord) => Promise<void>\n /** Return the artifact for `artifactId`, or `null` if none exists. */\n get: (artifactId: string) => Promise<ArtifactRecord | null>\n /**\n * All artifacts for a run in deterministic snapshot order: `createdAt`\n * ascending, then `artifactId` ascending by the unsigned UTF-8 bytes of\n * each string (compare bytes left-to-right; shorter equal prefixes first).\n * Returns `[]` when the run has none.\n */\n list: (runId: string) => Promise<Array<ArtifactRecord>>\n /**\n * All artifacts for a thread in deterministic snapshot order.\n * Records are ordered by `createdAt` ascending, then by `artifactId` using\n * the unsigned UTF-8 bytes of each string (compare bytes left-to-right; shorter\n * equal prefixes first).\n */\n listForThread: (threadId: string) => Promise<Array<ArtifactRecord>>\n /**\n * Delete a single artifact by id. A no-op if absent, mirroring\n * {@link BlobStore.delete} — the two are written and deleted as a pair, so\n * their contracts match.\n */\n delete: (artifactId: string) => Promise<void>\n /**\n * Delete every artifact belonging to `runId`. A no-op when the run has none.\n *\n * Required rather than feature-detected: retention and erasure are the point\n * of storing media durably, and an adapter silently lacking deletion is\n * indistinguishable from one where there was nothing to delete.\n */\n deleteForRun: (runId: string) => Promise<void>\n}\n\n/**\n * Accepted body shapes for {@link BlobStore.put}. `ArrayBufferView` already\n * covers `Uint8Array` and every other typed-array/`DataView`, so no separate\n * `Uint8Array` member is needed.\n */\nexport type BlobBody =\n | ReadableStream<Uint8Array>\n | ArrayBuffer\n | ArrayBufferView\n | string\n | Blob\n\n/**\n * Metadata for a stored blob.\n *\n * @property size - Byte length, when known.\n * @property createdAt - Epoch ms first written.\n * @property updatedAt - Epoch ms last overwritten.\n */\nexport interface BlobRecord {\n key: string\n size?: number\n etag?: string\n contentType?: string\n customMetadata?: Record<string, string>\n createdAt?: number\n updatedAt?: number\n}\n\n/**\n * A byte range to read, in the shape an HTTP `Range` header resolves to.\n *\n * `offset` is measured from the start of the object and must be inside it;\n * `length` defaults to \"everything from `offset` to the end\" and is clamped to\n * the end when it overshoots. Suffix ranges (`bytes=-500`) are the caller's to\n * resolve against the known size — a serve route has the size on the artifact\n * record, and has to compare against it anyway to answer `416` before reading.\n */\nexport interface BlobRange {\n offset: number\n length?: number\n}\n\n/** Options for {@link BlobStore.get}. */\nexport interface BlobGetOptions {\n /**\n * Read only this slice of the object. `body`, `arrayBuffer()` and `text()`\n * then cover the slice, `size` still reports the WHOLE object, and `range`\n * reports the slice actually served — the three numbers a `206` response\n * needs (`Content-Range: bytes <offset>-<offset+length-1>/<size>`).\n */\n range?: BlobRange\n}\n\n/** A stored blob's metadata plus lazy accessors for its bytes. */\nexport interface BlobObject extends BlobRecord {\n arrayBuffer: () => Promise<ArrayBuffer>\n text: () => Promise<string>\n body?: ReadableStream<Uint8Array>\n /**\n * The slice this object exposes, when a {@link BlobGetOptions.range} was\n * requested and honoured: `offset` as asked, `length` as actually served\n * (clamped to the end of the object). Absent on a whole-object read.\n */\n range?: { offset: number; length: number }\n}\n\n/**\n * One page of a {@link BlobStore.list} scan.\n *\n * @property cursor - Opaque continuation token; present only when `truncated`.\n * @property truncated - `true` when more objects match beyond this page.\n */\nexport interface BlobListPage {\n objects: Array<BlobRecord>\n cursor?: string\n truncated?: boolean\n}\n\nexport interface BlobPutOptions {\n contentType?: string\n customMetadata?: Record<string, string>\n /**\n * The exact byte length of `body`, when the producer knows it up front.\n *\n * Advisory, not a contract the store must honor: it exists so a store can\n * pick an upload strategy knowingly instead of discovering the length by\n * buffering. Most useful to an SDK that wants the length as a separate\n * argument rather than reading it off the stream — S3's `PutObject`\n * (`ContentLength`) is the archetype — and to a runtime that can re-attach\n * one (workerd's `FixedLengthStream` ahead of `R2Bucket.put`).\n *\n * Only ever set when the length is exact — a wrong value is worse than none,\n * since runtimes that enforce declared lengths fail the write. Absent means\n * unknown, and a store must accept a length-less stream regardless:\n * producers hand one over whenever the origin does not declare a length.\n */\n expectedLength?: number\n}\n\nexport interface BlobListOptions {\n prefix?: string\n cursor?: string\n limit?: number\n}\n\n/** Durable object/blob store (byte-storing or reference-only backends). */\nexport interface BlobStore {\n /** Insert or overwrite the object at `key`, returning its metadata. */\n put: (\n key: string,\n body: BlobBody,\n options?: BlobPutOptions,\n ) => Promise<BlobRecord>\n /**\n * Return the object at `key` (metadata + byte accessors), or `null`.\n *\n * RANGE SEMANTICS: with `options.range`, return only that slice — the bytes\n * a `206` response carries — and report it back as `range`. `size` still\n * reports the whole object, so the caller can build `Content-Range` without\n * a second `head`. The reported `length` is what was actually served: a\n * requested `length` past the end clamps. An `offset` at or past the end is\n * a caller error, not a store one — the size is on the artifact record, so a\n * serve route answers `416` before ever asking the store.\n *\n * Range support is part of the contract for any store that holds bytes (the\n * conformance testkit asserts it): serving a whole file where a slice was\n * asked for is what makes `<video>` seeking, and Safari playback at all,\n * fail. A reference-only backend that stores no bytes skips `blobs`\n * entirely rather than half-implementing it.\n */\n get: (key: string, options?: BlobGetOptions) => Promise<BlobObject | null>\n /** Return only the metadata for `key`, or `null`. */\n head: (key: string) => Promise<BlobRecord | null>\n /** Remove the object at `key`. A no-op if absent. */\n delete: (key: string) => Promise<void>\n /**\n * List objects, optionally filtered by `prefix`, in ascending key order.\n *\n * CURSOR SEMANTICS: `prefix` matches literally and case-sensitively (SQL\n * backends must escape LIKE metacharacters, so `run_` matches only the exact\n * bytes `run_`, not `_` as a wildcard). When `limit` is given and more keys\n * match, the page is `truncated: true` with a `cursor`; passing that `cursor`\n * back returns the strictly-following keys (keys `> cursor`). Cursor ordering\n * is the same byte ordering as the sort, so paging visits every key exactly\n * once with no gaps or repeats. `limit: 0` yields an empty, untruncated page\n * with no cursor.\n */\n list: (options?: BlobListOptions) => Promise<BlobListPage>\n}\n\n/**\n * Sparse bag of **state** store keys — composition / validation only.\n *\n * **Not a public product shape.** Prefer the named chat shapes below\n * ({@link ChatTranscriptStores}, {@link ChatPersistenceStores},\n * {@link ChatWithInterruptsStores}). Locks are not included — use\n * `withLocks` from `@tanstack/ai`.\n *\n * @internal Exported from this module for generics; the package root does not\n * re-export this type — use a named shape or `AIPersistence<{ … }>` instead.\n */\nexport interface AIPersistenceStores {\n messages?: MessageStore\n runs?: RunStore\n interrupts?: InterruptStore\n metadata?: MetadataStore\n generationRuns?: GenerationRunStore\n artifacts?: ArtifactStore\n blobs?: BlobStore\n}\n\n/**\n * Chat floor: durable transcript. `messages` is required.\n *\n * `runs` / `interrupts` / `metadata` remain optional. If `interrupts` is set,\n * `runs` is required (enforced by `withPersistence` / validators).\n */\nexport interface ChatTranscriptStores {\n messages: MessageStore\n runs?: RunStore\n interrupts?: InterruptStore\n metadata?: MetadataStore\n}\n\n/**\n * Full chat durability — all four state stores are present. This is what\n * `memoryPersistence()` returns, and the shape most adapters should declare.\n *\n * Backends that only need a transcript should use\n * {@link ChatTranscriptStores} instead.\n */\nexport interface ChatPersistenceStores {\n messages: MessageStore\n runs: RunStore\n interrupts: InterruptStore\n metadata: MetadataStore\n}\n\n/**\n * Chat with durable human-in-the-loop interrupts (and optional metadata).\n * Implies `runs` (interrupt records are run-scoped).\n *\n * Prefer {@link ChatPersistenceStores} when you also have metadata (packaged\n * backends). Use this when interrupts are required but metadata is not.\n */\nexport interface ChatWithInterruptsStores {\n messages: MessageStore\n runs: RunStore\n interrupts: InterruptStore\n metadata?: MetadataStore\n}\n\n/**\n * Persistence aggregate. Parameterize with a named store shape, or a sparse\n * map for composition (`defineAIPersistence` / `composePersistence`).\n *\n * Default is the sparse bag so untyped / dynamic bags still type-check;\n * prefer {@link ChatTranscriptPersistence} or {@link ChatPersistence} at\n * call sites.\n */\nexport interface AIPersistence<\n TStores extends AIPersistenceStores = AIPersistenceStores,\n> {\n stores: ExactStoreKeys<TStores>\n}\n\n/** {@link AIPersistence} for {@link ChatTranscriptStores}. */\nexport type ChatTranscriptPersistence = AIPersistence<ChatTranscriptStores>\n\n/** {@link AIPersistence} for {@link ChatPersistenceStores}. */\nexport type ChatPersistence = AIPersistence<ChatPersistenceStores>\n\n/** {@link AIPersistence} for {@link ChatWithInterruptsStores}. */\nexport type ChatWithInterruptsPersistence =\n AIPersistence<ChatWithInterruptsStores>\n\ntype StoreKey = keyof AIPersistenceStores\ntype ExactStoreKeys<TStores> =\n Exclude<keyof TStores, StoreKey> extends never\n ? TStores\n : TStores & Record<Exclude<keyof TStores, StoreKey>, never>\n\nexport type AIPersistenceOverrides = {\n [TKey in StoreKey]?: AIPersistenceStores[TKey] | false\n}\n\ntype BaseStoreValue<\n TBase extends AIPersistenceStores,\n TKey extends StoreKey,\n> = TKey extends keyof TBase ? TBase[TKey] : never\n\ntype OverrideStoreValue<\n TOverrides extends AIPersistenceOverrides,\n TKey extends StoreKey,\n> = TKey extends keyof TOverrides ? TOverrides[TKey] : never\n\ntype ResolvedStoreValue<\n TBase extends AIPersistenceStores,\n TOverrides extends AIPersistenceOverrides,\n TKey extends StoreKey,\n> = TKey extends keyof TOverrides\n ?\n | Exclude<OverrideStoreValue<TOverrides, TKey>, false | undefined>\n | (undefined extends OverrideStoreValue<TOverrides, TKey>\n ? Exclude<BaseStoreValue<TBase, TKey>, undefined>\n : never)\n : Exclude<BaseStoreValue<TBase, TKey>, undefined>\n\ntype BaseStoreIsRequired<\n TBase extends AIPersistenceStores,\n TKey extends StoreKey,\n> = TKey extends keyof TBase\n ? object extends Pick<TBase, TKey>\n ? false\n : true\n : false\n\ntype ResolvedStoreIsRequired<\n TBase extends AIPersistenceStores,\n TOverrides extends AIPersistenceOverrides,\n TKey extends StoreKey,\n> = TKey extends keyof TOverrides\n ? false extends OverrideStoreValue<TOverrides, TKey>\n ? false\n : undefined extends OverrideStoreValue<TOverrides, TKey>\n ? BaseStoreIsRequired<TBase, TKey>\n : true\n : BaseStoreIsRequired<TBase, TKey>\n\ntype ResolvedRequiredKeys<\n TBase extends AIPersistenceStores,\n TOverrides extends AIPersistenceOverrides,\n> = {\n [TKey in StoreKey]-?: [ResolvedStoreValue<TBase, TOverrides, TKey>] extends [\n never,\n ]\n ? never\n : ResolvedStoreIsRequired<TBase, TOverrides, TKey> extends true\n ? TKey\n : never\n}[StoreKey]\n\ntype ResolvedOptionalKeys<\n TBase extends AIPersistenceStores,\n TOverrides extends AIPersistenceOverrides,\n> = {\n [TKey in StoreKey]-?: [ResolvedStoreValue<TBase, TOverrides, TKey>] extends [\n never,\n ]\n ? never\n : ResolvedStoreIsRequired<TBase, TOverrides, TKey> extends true\n ? never\n : TKey\n}[StoreKey]\n\ntype Simplify<T> = { [TKey in keyof T]: T[TKey] }\n\nexport type ComposedAIPersistenceStores<\n TBase extends AIPersistenceStores,\n TOverrides extends AIPersistenceOverrides,\n> = Simplify<\n {\n [TKey in ResolvedRequiredKeys<TBase, TOverrides>]: ResolvedStoreValue<\n TBase,\n TOverrides,\n TKey\n >\n } & {\n [TKey in ResolvedOptionalKeys<TBase, TOverrides>]?: ResolvedStoreValue<\n TBase,\n TOverrides,\n TKey\n >\n }\n>\n\nconst storeKeys = [\n 'messages',\n 'runs',\n 'generationRuns',\n 'interrupts',\n 'metadata',\n 'artifacts',\n 'blobs',\n] satisfies Array<StoreKey>\n\nconst storeKeySet = new Set<string>(storeKeys)\n\nfunction assertKnownStoreKeys(stores: object, location: string): void {\n for (const key of Object.keys(stores)) {\n if (!storeKeySet.has(key)) {\n throw new Error(`Unknown AIPersistence ${location} key: ${key}`)\n }\n }\n}\n\nexport function validatePersistenceStoreKeys(persistence: AIPersistence): void {\n assertKnownStoreKeys(persistence.stores, 'store')\n}\n\n/**\n * Chat middleware entrypoint rules:\n * - `messages` is required (chat persistence means a durable transcript)\n * - `interrupts` requires `runs` (interrupt records are run-scoped)\n */\nexport function validateChatPersistenceStores(\n persistence: AIPersistence,\n): void {\n validatePersistenceStoreKeys(persistence)\n if (!persistence.stores.messages) {\n throw new Error('Chat persistence requires stores.messages.')\n }\n if (persistence.stores.interrupts && !persistence.stores.runs) {\n throw new Error('Chat persistence stores.interrupts requires stores.runs.')\n }\n}\n\n/**\n * Generation middleware entrypoint rule: `generationRuns` is required (the\n * generation run lifecycle is keyed on its own `runId`, not a chat conversation\n * `threadId`). When artifact persistence is used, `artifacts` and `blobs` must\n * be provided together.\n */\nexport function validateGenerationPersistenceStores(\n persistence: AIPersistence,\n): void {\n validatePersistenceStoreKeys(persistence)\n const hasArtifacts = persistence.stores.artifacts !== undefined\n const hasBlobs = persistence.stores.blobs !== undefined\n if (hasArtifacts !== hasBlobs) {\n throw new Error(\n 'Generation artifact persistence requires both stores.artifacts and stores.blobs.',\n )\n }\n if (!persistence.stores.generationRuns) {\n throw new Error('Generation persistence requires stores.generationRuns.')\n }\n}\n\n/**\n * Server hydrate entrypoint rule: `messages` is required.\n */\nexport function validateReconstructChatStores(\n persistence: AIPersistence,\n): void {\n validatePersistenceStoreKeys(persistence)\n if (!persistence.stores.messages) {\n throw new Error('reconstructChat requires stores.messages.')\n }\n}\n\n/**\n * Server hydrate entrypoint rule for generation: `generationRuns` is required.\n * The run store resolves the latest generation for a thread (or a specific run\n * id), so a server-authoritative client can hydrate the last generation's\n * status, result, and artifact refs on load.\n */\nexport function validateReconstructGenerationStores(\n persistence: AIPersistence,\n): void {\n validatePersistenceStoreKeys(persistence)\n if (!persistence.stores.generationRuns) {\n throw new Error('reconstructGeneration requires stores.generationRuns.')\n }\n}\n\nexport function defineAIPersistence<TStores extends AIPersistenceStores>(\n persistence: AIPersistence<ExactStoreKeys<TStores>>,\n): AIPersistence<TStores> {\n validatePersistenceStoreKeys(persistence)\n return persistence\n}\n\nexport function composePersistence<\n TBase extends AIPersistenceStores,\n TOverrides extends AIPersistenceOverrides,\n>(\n base: AIPersistence<TBase>,\n config: {\n overrides: ExactStoreKeys<TOverrides>\n },\n): AIPersistence<ComposedAIPersistenceStores<TBase, TOverrides>>\nexport function composePersistence(\n base: AIPersistence,\n config: { overrides: AIPersistenceOverrides },\n): AIPersistence {\n validatePersistenceStoreKeys(base)\n assertKnownStoreKeys(config.overrides, 'override')\n\n const stores: AIPersistenceStores = { ...base.stores }\n for (const key of storeKeys) {\n if (!Object.prototype.hasOwnProperty.call(config.overrides, key)) continue\n const override = config.overrides[key]\n if (override === false) {\n delete stores[key]\n } else if (override !== undefined) {\n setStore(stores, key, override)\n }\n }\n return { stores }\n}\n\nfunction setStore<TKey extends StoreKey>(\n stores: AIPersistenceStores,\n key: TKey,\n value: NonNullable<AIPersistenceStores[TKey]>,\n): void {\n stores[key] = value\n}\n"],"mappings":";;;AAkUA,SAAgB,mBAAmB,OAAmC;CACpE,OAAO;AACT;;AAEA,SAAgB,qBAAqB,OAAuC;CAC1E,OAAO;AACT;;AAEA,SAAgB,oBAAoB,OAAqC;CACvE,OAAO;AACT;;AAEA,SAAgB,yBACd,OACoB;CACpB,OAAO;AACT;;AAEA,SAAgB,oBAAoB,OAAqC;CACvE,OAAO;AACT;;AAEA,SAAgB,gBAAgB,OAA6B;CAC3D,OAAO;AACT;AAsZA,IAAM,YAAY;CAChB;CACA;CACA;CACA;CACA;CACA;CACA;AACF;AAEA,IAAM,cAAc,IAAI,IAAY,SAAS;AAE7C,SAAS,qBAAqB,QAAgB,UAAwB;CACpE,KAAK,MAAM,OAAO,OAAO,KAAK,MAAM,GAClC,IAAI,CAAC,YAAY,IAAI,GAAG,GACtB,MAAM,IAAI,MAAM,yBAAyB,SAAS,QAAQ,KAAK;AAGrE;AAEA,SAAgB,6BAA6B,aAAkC;CAC7E,qBAAqB,YAAY,QAAQ,OAAO;AAClD;;;;;;AAOA,SAAgB,8BACd,aACM;CACN,6BAA6B,WAAW;CACxC,IAAI,CAAC,YAAY,OAAO,UACtB,MAAM,IAAI,MAAM,4CAA4C;CAE9D,IAAI,YAAY,OAAO,cAAc,CAAC,YAAY,OAAO,MACvD,MAAM,IAAI,MAAM,0DAA0D;AAE9E;;;;;;;AAQA,SAAgB,oCACd,aACM;CACN,6BAA6B,WAAW;CAGxC,IAFqB,YAAY,OAAO,cAAc,KAAA,OACrC,YAAY,OAAO,UAAU,KAAA,IAE5C,MAAM,IAAI,MACR,kFACF;CAEF,IAAI,CAAC,YAAY,OAAO,gBACtB,MAAM,IAAI,MAAM,wDAAwD;AAE5E;;;;AAKA,SAAgB,8BACd,aACM;CACN,6BAA6B,WAAW;CACxC,IAAI,CAAC,YAAY,OAAO,UACtB,MAAM,IAAI,MAAM,2CAA2C;AAE/D;;;;;;;AAQA,SAAgB,oCACd,aACM;CACN,6BAA6B,WAAW;CACxC,IAAI,CAAC,YAAY,OAAO,gBACtB,MAAM,IAAI,MAAM,uDAAuD;AAE3E;AAEA,SAAgB,oBACd,aACwB;CACxB,6BAA6B,WAAW;CACxC,OAAO;AACT;AAWA,SAAgB,mBACd,MACA,QACe;CACf,6BAA6B,IAAI;CACjC,qBAAqB,OAAO,WAAW,UAAU;CAEjD,MAAM,SAA8B,EAAE,GAAG,KAAK,OAAO;CACrD,KAAK,MAAM,OAAO,WAAW;EAC3B,IAAI,CAAC,OAAO,UAAU,eAAe,KAAK,OAAO,WAAW,GAAG,GAAG;EAClE,MAAM,WAAW,OAAO,UAAU;EAClC,IAAI,aAAa,OACf,OAAO,OAAO;OACT,IAAI,aAAa,KAAA,GACtB,SAAS,QAAQ,KAAK,QAAQ;CAElC;CACA,OAAO,EAAE,OAAO;AAClB;AAEA,SAAS,SACP,QACA,KACA,OACM;CACN,OAAO,OAAO;AAChB"}
1
+ {"version":3,"file":"types.js","names":[],"sources":["../../src/types.ts"],"sourcesContent":["import type {\n ModelMessage,\n MetadataStore,\n PersistedArtifactRef,\n RunStatus,\n RunStore,\n Scope,\n TokenUsage,\n} from '@tanstack/ai'\n\n// Re-export the shared identity type so app code can import Scope from either\n// `@tanstack/ai` or `@tanstack/ai-persistence`. See {@link Scope} security notes:\n// pair a client-visible `threadId` with a server-trusted `userId`/`tenantId`\n// before authorizing load/save (e.g. via `reconstructChat({ authorize })`).\nexport type { MetadataStore, Scope }\n\n// ===========================================================================\n// Store contracts\n// ===========================================================================\n//\n// EVOLUTION POLICY\n// ----------------\n// These store interfaces are the compatibility surface between the core\n// middleware and every backend — the in-memory reference store and every\n// adapter an application writes against its own database.\n//\n// - Store METHODS are REQUIRED. A new method is a breaking contract change:\n// every adapter gets a compile error and implements it. Do NOT add methods\n// as optional-and-feature-detected (`store.method?.(...)`) — an adapter\n// that has not implemented one is then indistinguishable from one whose\n// answer is legitimately empty, so the feature silently does nothing in\n// production instead of failing at build time. `findActiveRun` was optional\n// for exactly one release cycle and cost us precisely that: reconnect\n// degraded to \"no active run\" on every backend that had not caught up.\n// - Capability tiers belong at the STORE level, not the method level. A\n// backend that only stores a transcript declares `ChatTranscriptStores`\n// (no `runs`); it does not declare a half-implemented `RunStore`.\n// - Never tighten an existing method's required arguments or widen its\n// required return shape in a breaking way.\n//\n// The shared conformance testkit (`./testkit/conformance.ts`) is the\n// authoritative compatibility gate: every invariant documented on the methods\n// below is asserted there, and every backend runs the identical suite. If an\n// invariant is not encoded in the testkit, adapters cannot discover it — so\n// promote new invariants into both the JSDoc here AND the testkit.\n//\n// TIMESTAMP CONVENTION\n// --------------------\n// Store *records* (`RunRecord`, `InterruptRecord`, `ArtifactRecord`,\n// `BlobRecord`) speak **epoch milliseconds** (`number`), the native unit for\n// SQL/`BIGINT` columns and `Date.now()`. Wire/result references that leave the\n// persistence layer (e.g. core's `PersistedArtifactRef.createdAt`) speak\n// **ISO-8601 strings**. The middleware performs the number→ISO conversion at\n// the boundary; do not mix the two on a single field.\n\n/**\n * One page of a thread from {@link MessageStore.loadThread} when the caller\n * passed a paging hint.\n *\n * Middleware omits the hint and always gets a full `Array<ModelMessage>`,\n * never this shape.\n *\n * `truncated: true` requires `cursor`. Without a cursor the client cannot\n * request the next older window, so `reconstructChat` treats that page as\n * complete.\n */\nexport type MessagePage =\n | {\n messages: Array<ModelMessage>\n truncated: false\n cursor?: never\n }\n | {\n messages: Array<ModelMessage>\n truncated: true\n cursor: string\n }\n\n/**\n * Durable store for a thread's full message transcript.\n *\n * A \"thread\" is the unit of conversation history. The key is\n * {@link Scope.threadId} (the same conversation id as\n * `ChatMiddlewareContext.threadId`). Store methods take a bare string for\n * adapter simplicity; multi-user isolation is the **host's** job — authorize\n * against `Scope.userId` / `Scope.tenantId` (derived server-side from session)\n * before calling load/save, and never treat a client-supplied thread id alone\n * as an ownership proof (see `Scope` security notes in `@tanstack/ai`).\n *\n * `saveThread` always receives and persists the **complete, authoritative**\n * message list — it is an overwrite, never an append. The middleware snapshots\n * `ctx.messages` (the full running transcript) into it.\n */\nexport interface MessageStore {\n /**\n * Return the stored transcript for `threadId` ({@link Scope.threadId}),\n * in insertion order.\n *\n * Call with only `threadId` (middleware, `onStart`, `onFinish`) and this\n * MUST return the full transcript as an `Array<ModelMessage>`. Never a\n * {@link MessagePage}.\n *\n * `options.limit` and `options.before` are an optional paging hint for\n * hydrate. Adapters may ignore them and still return the full array. An\n * adapter that pages returns a {@link MessagePage}.\n *\n * INVARIANT: returns an empty array (never `null`/`undefined`) for a thread\n * that was never saved. Callers treat `[]` as \"no history\".\n */\n loadThread: {\n (threadId: string): Promise<Array<ModelMessage>>\n (\n threadId: string,\n options: { limit?: number; before?: string },\n ): Promise<Array<ModelMessage> | MessagePage>\n }\n /**\n * Overwrite the stored transcript for `threadId` with `messages`.\n *\n * INVARIANT: this is a full replace. `messages` is the complete authoritative\n * history; the previous contents are discarded (not merged or appended).\n */\n saveThread: (threadId: string, messages: Array<ModelMessage>) => Promise<void>\n}\n\n// Run lifecycle types live in `@tanstack/ai` and are re-exported here: one run,\n// one record — shared by this package's `runs` store and `@tanstack/ai-sandbox`'s\n// run driver, instead of each package keeping a rival definition that can drift.\nexport type {\n RunStatus,\n TerminalRunStatus,\n RunRecord,\n RunStore,\n} from '@tanstack/ai'\nexport { isTerminalRunStatus, defineRunStore } from '@tanstack/ai'\n\n/**\n * Lifecycle status of a generation run. Deliberately the same vocabulary as\n * {@link RunStatus}, so an adapter that stores both kinds of run can share one\n * status column and one set of checks.\n */\nexport type GenerationRunStatus = RunStatus\n\n/**\n * A single generation run (one `generateImage` / `generateVideo` / … call).\n *\n * Its primary identity is `runId`: the run/request id the activity mints, the\n * same AG-UI run id the client sends on the wire. `threadId` is the SLOT the\n * run fills, a stable app-chosen name that groups successive runs of the same\n * thing, and it is what a server-driven client hydrates by. Generation state is\n * kept here, never in the chat {@link RunStore}.\n *\n * `result` holds terminal result METADATA (ids, model, urls, a provider video\n * job id), never the media bytes — those live in a {@link BlobStore}.\n * `artifacts` are the durable {@link PersistedArtifactRef}s, present only when\n * byte storage is on.\n *\n * @property startedAt - Epoch ms when the run was first created.\n * @property finishedAt - Epoch ms when the run reached a terminal status.\n */\nexport interface GenerationRunRecord {\n runId: string\n /**\n * The scope this run belongs to: a stable, app-chosen name for the slot\n * successive runs fill (`product-123-hero`, `video-9-start-frame`).\n *\n * REQUIRED, per the store-contract rule at the top of this file.\n * {@link GenerationRunStore.findLatestForThread} is the only query that\n * hydrates a run, and it keys on this — so a record without one can be\n * written and then never found again. `withGenerationPersistence` already\n * refuses to start a run without a scope, and a server-driven client\n * discards a snapshot that arrives without one, so an optional field here\n * only described a record no path could produce and no client would accept.\n */\n threadId: string\n /** `'image' | 'audio' | 'tts' | 'video' | 'transcription'`. */\n activity: string\n provider: string\n model: string\n status: GenerationRunStatus\n startedAt: number\n finishedAt?: number\n error?: { message: string; code?: string }\n /** Terminal result metadata (ids, model, urls). Never the media bytes. */\n result?: unknown\n /** Durable artifact references, when an artifacts + blobs backend is used. */\n artifacts?: Array<PersistedArtifactRef>\n usage?: TokenUsage\n}\n\n/**\n * Durable store for generation run records, the generation counterpart to\n * {@link RunStore}. Keyed by its own `runId`, with `threadId` the slot\n * {@link GenerationRunStore.findLatestForThread} looks runs up by.\n */\nexport interface GenerationRunStore {\n /**\n * Create a run record, or return the existing one if `runId` is already\n * present (resume).\n *\n * INVARIANT (idempotency): a second call for a `runId` returns the existing\n * record unchanged; `startedAt`/`activity`/`provider`/`model`/`threadId` are\n * not mutated. `status` defaults to `'running'` on first creation.\n */\n createOrResume: (\n input: Pick<\n GenerationRunRecord,\n 'runId' | 'threadId' | 'activity' | 'provider' | 'model' | 'startedAt'\n > & { status?: GenerationRunStatus },\n ) => Promise<GenerationRunRecord>\n /**\n * Patch a run record's mutable fields.\n *\n * INVARIANT: patching a `runId` that does not exist is a **no-op** — it must\n * not throw and must not create a record.\n */\n update: (\n runId: string,\n patch: Partial<\n Pick<\n GenerationRunRecord,\n 'status' | 'finishedAt' | 'error' | 'result' | 'artifacts' | 'usage'\n >\n >,\n ) => Promise<void>\n /** Return the run record for `runId`, or `null` if none exists. */\n get: (runId: string) => Promise<GenerationRunRecord | null>\n /**\n * The most recent run linked to `threadId`, or `null`.\n *\n * REQUIRED, per the store-contract rule at the top of this file: a\n * server-authoritative client hydrates by the stable thread id on every\n * mount, so an adapter without this would be indistinguishable from one that\n * legitimately has no run — `persistence: true` would silently restore\n * nothing, forever. `null` is the correct answer only when the thread really\n * has no runs. The chat parallel is {@link RunStore.findActiveRun}.\n */\n findLatestForThread: (threadId: string) => Promise<GenerationRunRecord | null>\n}\n\n/** Lifecycle status of a human-in-the-loop interrupt. */\nexport type InterruptStatus = 'pending' | 'resolved' | 'cancelled'\n\n/**\n * A human-in-the-loop interrupt (tool approval, client-tool input request, …).\n *\n * @property requestedAt - Epoch ms when the interrupt was created.\n * @property resolvedAt - Epoch ms when the interrupt was resolved/cancelled;\n * absent while pending.\n */\nexport interface InterruptRecord {\n interruptId: string\n runId: string\n threadId: string\n status: InterruptStatus\n requestedAt: number\n resolvedAt?: number\n payload: Record<string, unknown>\n response?: unknown\n}\n\n/** A terminal interrupt write for {@link InterruptStore.commitBatch}. */\nexport type InterruptCommitEntry =\n | {\n interruptId: string\n status: 'resolved'\n response?: unknown\n }\n | {\n interruptId: string\n status: 'cancelled'\n }\n\n/** Durable store for human-in-the-loop interrupts. */\nexport interface InterruptStore {\n /**\n * Persist a new interrupt in the `'pending'` state.\n *\n * The record is accepted without `status`/`resolvedAt` so a \"born resolved\"\n * interrupt is unrepresentable — every interrupt begins pending and only\n * `resolve`/`cancel` may move it to a terminal state.\n *\n * INVARIANT (insert-if-absent): if an interrupt with the same `interruptId`\n * already exists, `create` is a **no-op** — it must NOT overwrite the\n * existing record. This is the canonical behaviour (SQL backends implement it\n * via `ON CONFLICT DO NOTHING` / upsert-with-empty-update), so a duplicate\n * create can never clobber a resolved interrupt back to pending.\n */\n create: (\n record: Omit<InterruptRecord, 'status' | 'resolvedAt'>,\n ) => Promise<void>\n /**\n * Move an interrupt to `'resolved'`, stamping `resolvedAt` and storing\n * `response`. A no-op if `interruptId` does not exist.\n */\n resolve: (interruptId: string, response?: unknown) => Promise<void>\n /**\n * Move an interrupt to `'cancelled'`, stamping `resolvedAt`. A no-op if\n * `interruptId` does not exist.\n */\n cancel: (interruptId: string) => Promise<void>\n /**\n * Commit terminal writes for a validated resume batch.\n *\n * Optional. When present, `withPersistence` calls it once instead of\n * calling `resolve` and `cancel` for each entry. Apply every entry or none.\n *\n * Reject the whole batch (throw, writing nothing) when any entry has a\n * duplicate `interruptId`, references an `interruptId` that does not exist,\n * or references an interrupt whose status is not `'pending'`. This is\n * stricter than `resolve` / `cancel`, which are no-ops for a missing\n * `interruptId`.\n */\n commitBatch?: (entries: ReadonlyArray<InterruptCommitEntry>) => Promise<void>\n /** Return the interrupt for `interruptId`, or `null` if none exists. */\n get: (interruptId: string) => Promise<InterruptRecord | null>\n /**\n * All interrupts for a thread.\n *\n * INVARIANT: ordered by insertion (equivalently `requestedAt` ascending). SQL\n * backends MUST `ORDER BY requested_at` — the middleware and testkit rely on\n * this stable ordering.\n */\n list: (threadId: string) => Promise<Array<InterruptRecord>>\n /** Pending interrupts for a thread, ordered by `requestedAt` ascending. */\n listPending: (threadId: string) => Promise<Array<InterruptRecord>>\n /** All interrupts for a run, ordered by `requestedAt` ascending. */\n listByRun: (runId: string) => Promise<Array<InterruptRecord>>\n /** Pending interrupts for a run, ordered by `requestedAt` ascending. */\n listPendingByRun: (runId: string) => Promise<Array<InterruptRecord>>\n}\n\n// ===========================================================================\n// Store typers\n// ===========================================================================\n//\n// Identity helpers that type a store implementation inline: pass an object\n// literal and get autocomplete + contract checking, with no separate\n// `: MessageStore` return annotation. They compose into `defineAIPersistence`,\n// which infers **exact presence** — a store you define becomes a defined,\n// non-optional, autocompleted key on `persistence.stores`, and accessing a store\n// you did not define is a compile error.\n//\n// ```ts\n// const persistence = defineAIPersistence({\n// stores: {\n// messages: defineMessageStore({ loadThread, saveThread }),\n// runs: defineRunStore({ createOrResume, update, get, findActiveRun }),\n// },\n// })\n// persistence.stores.runs // RunStore (defined)\n// persistence.stores.interrupts // compile error — not provided\n// ```\n//\n// Presence is per STORE, not per method: every method of a store you define is\n// required (see the evolution policy above). Omitting one is a compile error,\n// not a partial store.\n\n/** Type a {@link MessageStore} implementation inline. */\nexport function defineMessageStore(store: MessageStore): MessageStore {\n return store\n}\n/** Type an {@link InterruptStore} implementation inline. */\nexport function defineInterruptStore(store: InterruptStore): InterruptStore {\n return store\n}\n/** Type a {@link MetadataStore} implementation inline. */\nexport function defineMetadataStore(store: MetadataStore): MetadataStore {\n return store\n}\n/** Type a {@link GenerationRunStore} implementation inline. */\nexport function defineGenerationRunStore(\n store: GenerationRunStore,\n): GenerationRunStore {\n return store\n}\n/** Type an {@link ArtifactStore} implementation inline. */\nexport function defineArtifactStore(store: ArtifactStore): ArtifactStore {\n return store\n}\n/** Type a {@link BlobStore} implementation inline. */\nexport function defineBlobStore(store: BlobStore): BlobStore {\n return store\n}\n\n/**\n * Metadata row describing a persisted artifact (generated media, tool output).\n *\n * The bytes themselves live in a {@link BlobStore}; this record holds the\n * descriptive metadata and an optional `sourceUrl` for reference-only\n * backends.\n *\n * @property createdAt - Epoch ms. (Core's wire-facing `PersistedArtifactRef`\n * exposes the same instant as an ISO string; see the timestamp convention.)\n */\nexport interface ArtifactRecord {\n artifactId: string\n runId: string\n threadId: string\n /**\n * The blob-store key these bytes actually live under.\n *\n * Optional for backwards compatibility: records written before this existed\n * resolve via the default `artifacts/<runId>/<artifactId>` convention. New\n * records always carry it, which is what lets `storageKey` put bytes anywhere\n * — a reader can no longer recompute the path, so it has to be remembered.\n * Use `resolveArtifactBlobKey(record)` rather than reading it directly.\n */\n blobKey?: string\n name: string\n mimeType: string\n size: number\n sourceUrl?: string\n createdAt: number\n}\n\n/** Durable store for artifact metadata records. */\nexport interface ArtifactStore {\n /** Insert or overwrite the artifact metadata record. */\n save: (record: ArtifactRecord) => Promise<void>\n /** Return the artifact for `artifactId`, or `null` if none exists. */\n get: (artifactId: string) => Promise<ArtifactRecord | null>\n /**\n * All artifacts for a run in deterministic snapshot order: `createdAt`\n * ascending, then `artifactId` ascending by the unsigned UTF-8 bytes of\n * each string (compare bytes left-to-right; shorter equal prefixes first).\n * Returns `[]` when the run has none.\n */\n list: (runId: string) => Promise<Array<ArtifactRecord>>\n /**\n * All artifacts for a thread in deterministic snapshot order.\n * Records are ordered by `createdAt` ascending, then by `artifactId` using\n * the unsigned UTF-8 bytes of each string (compare bytes left-to-right; shorter\n * equal prefixes first).\n */\n listForThread: (threadId: string) => Promise<Array<ArtifactRecord>>\n /**\n * Delete a single artifact by id. A no-op if absent, mirroring\n * {@link BlobStore.delete} — the two are written and deleted as a pair, so\n * their contracts match.\n */\n delete: (artifactId: string) => Promise<void>\n /**\n * Delete every artifact belonging to `runId`. A no-op when the run has none.\n *\n * Required rather than feature-detected: retention and erasure are the point\n * of storing media durably, and an adapter silently lacking deletion is\n * indistinguishable from one where there was nothing to delete.\n */\n deleteForRun: (runId: string) => Promise<void>\n}\n\n/**\n * Accepted body shapes for {@link BlobStore.put}. `ArrayBufferView` already\n * covers `Uint8Array` and every other typed-array/`DataView`, so no separate\n * `Uint8Array` member is needed.\n */\nexport type BlobBody =\n | ReadableStream<Uint8Array>\n | ArrayBuffer\n | ArrayBufferView\n | string\n | Blob\n\n/**\n * Metadata for a stored blob.\n *\n * @property size - Byte length, when known.\n * @property createdAt - Epoch ms first written.\n * @property updatedAt - Epoch ms last overwritten.\n */\nexport interface BlobRecord {\n key: string\n size?: number\n etag?: string\n contentType?: string\n customMetadata?: Record<string, string>\n createdAt?: number\n updatedAt?: number\n}\n\n/**\n * A byte range to read, in the shape an HTTP `Range` header resolves to.\n *\n * `offset` is measured from the start of the object and must be inside it;\n * `length` defaults to \"everything from `offset` to the end\" and is clamped to\n * the end when it overshoots. Suffix ranges (`bytes=-500`) are the caller's to\n * resolve against the known size — a serve route has the size on the artifact\n * record, and has to compare against it anyway to answer `416` before reading.\n */\nexport interface BlobRange {\n offset: number\n length?: number\n}\n\n/** Options for {@link BlobStore.get}. */\nexport interface BlobGetOptions {\n /**\n * Read only this slice of the object. `body`, `arrayBuffer()` and `text()`\n * then cover the slice, `size` still reports the WHOLE object, and `range`\n * reports the slice actually served — the three numbers a `206` response\n * needs (`Content-Range: bytes <offset>-<offset+length-1>/<size>`).\n */\n range?: BlobRange\n}\n\n/** A stored blob's metadata plus lazy accessors for its bytes. */\nexport interface BlobObject extends BlobRecord {\n arrayBuffer: () => Promise<ArrayBuffer>\n text: () => Promise<string>\n body?: ReadableStream<Uint8Array>\n /**\n * The slice this object exposes, when a {@link BlobGetOptions.range} was\n * requested and honoured: `offset` as asked, `length` as actually served\n * (clamped to the end of the object). Absent on a whole-object read.\n */\n range?: { offset: number; length: number }\n}\n\n/**\n * One page of a {@link BlobStore.list} scan.\n *\n * @property cursor - Opaque continuation token; present only when `truncated`.\n * @property truncated - `true` when more objects match beyond this page.\n */\nexport interface BlobListPage {\n objects: Array<BlobRecord>\n cursor?: string\n truncated?: boolean\n}\n\nexport interface BlobPutOptions {\n contentType?: string\n customMetadata?: Record<string, string>\n /**\n * The exact byte length of `body`, when the producer knows it up front.\n *\n * Advisory, not a contract the store must honor: it exists so a store can\n * pick an upload strategy knowingly instead of discovering the length by\n * buffering. Most useful to an SDK that wants the length as a separate\n * argument rather than reading it off the stream — S3's `PutObject`\n * (`ContentLength`) is the archetype — and to a runtime that can re-attach\n * one (workerd's `FixedLengthStream` ahead of `R2Bucket.put`).\n *\n * Only ever set when the length is exact — a wrong value is worse than none,\n * since runtimes that enforce declared lengths fail the write. Absent means\n * unknown, and a store must accept a length-less stream regardless:\n * producers hand one over whenever the origin does not declare a length.\n */\n expectedLength?: number\n}\n\nexport interface BlobListOptions {\n prefix?: string\n cursor?: string\n limit?: number\n}\n\n/** Durable object/blob store (byte-storing or reference-only backends). */\nexport interface BlobStore {\n /** Insert or overwrite the object at `key`, returning its metadata. */\n put: (\n key: string,\n body: BlobBody,\n options?: BlobPutOptions,\n ) => Promise<BlobRecord>\n /**\n * Return the object at `key` (metadata + byte accessors), or `null`.\n *\n * RANGE SEMANTICS: with `options.range`, return only that slice — the bytes\n * a `206` response carries — and report it back as `range`. `size` still\n * reports the whole object, so the caller can build `Content-Range` without\n * a second `head`. The reported `length` is what was actually served: a\n * requested `length` past the end clamps. An `offset` at or past the end is\n * a caller error, not a store one — the size is on the artifact record, so a\n * serve route answers `416` before ever asking the store.\n *\n * Range support is part of the contract for any store that holds bytes (the\n * conformance testkit asserts it): serving a whole file where a slice was\n * asked for is what makes `<video>` seeking, and Safari playback at all,\n * fail. A reference-only backend that stores no bytes skips `blobs`\n * entirely rather than half-implementing it.\n */\n get: (key: string, options?: BlobGetOptions) => Promise<BlobObject | null>\n /** Return only the metadata for `key`, or `null`. */\n head: (key: string) => Promise<BlobRecord | null>\n /** Remove the object at `key`. A no-op if absent. */\n delete: (key: string) => Promise<void>\n /**\n * List objects, optionally filtered by `prefix`, in ascending key order.\n *\n * CURSOR SEMANTICS: `prefix` matches literally and case-sensitively (SQL\n * backends must escape LIKE metacharacters, so `run_` matches only the exact\n * bytes `run_`, not `_` as a wildcard). When `limit` is given and more keys\n * match, the page is `truncated: true` with a `cursor`; passing that `cursor`\n * back returns the strictly-following keys (keys `> cursor`). Cursor ordering\n * is the same byte ordering as the sort, so paging visits every key exactly\n * once with no gaps or repeats. `limit: 0` yields an empty, untruncated page\n * with no cursor.\n */\n list: (options?: BlobListOptions) => Promise<BlobListPage>\n}\n\n/**\n * Sparse bag of **state** store keys — composition / validation only.\n *\n * **Not a public product shape.** Prefer the named chat shapes below\n * ({@link ChatTranscriptStores}, {@link ChatPersistenceStores},\n * {@link ChatWithInterruptsStores}). Locks are not included — use\n * `withLocks` from `@tanstack/ai`.\n *\n * @internal Exported from this module for generics; the package root does not\n * re-export this type — use a named shape or `AIPersistence<{ … }>` instead.\n */\nexport interface AIPersistenceStores {\n messages?: MessageStore\n runs?: RunStore\n interrupts?: InterruptStore\n metadata?: MetadataStore\n generationRuns?: GenerationRunStore\n artifacts?: ArtifactStore\n blobs?: BlobStore\n}\n\n/**\n * Chat floor: durable transcript. `messages` is required.\n *\n * `runs` / `interrupts` / `metadata` remain optional. If `interrupts` is set,\n * `runs` is required (enforced by `withPersistence` / validators).\n */\nexport interface ChatTranscriptStores {\n messages: MessageStore\n runs?: RunStore\n interrupts?: InterruptStore\n metadata?: MetadataStore\n}\n\n/**\n * Full chat durability — all four state stores are present. This is what\n * `memoryPersistence()` returns, and the shape most adapters should declare.\n *\n * Backends that only need a transcript should use\n * {@link ChatTranscriptStores} instead.\n */\nexport interface ChatPersistenceStores {\n messages: MessageStore\n runs: RunStore\n interrupts: InterruptStore\n metadata: MetadataStore\n}\n\n/**\n * Chat with durable human-in-the-loop interrupts (and optional metadata).\n * Implies `runs` (interrupt records are run-scoped).\n *\n * Prefer {@link ChatPersistenceStores} when you also have metadata (packaged\n * backends). Use this when interrupts are required but metadata is not.\n */\nexport interface ChatWithInterruptsStores {\n messages: MessageStore\n runs: RunStore\n interrupts: InterruptStore\n metadata?: MetadataStore\n}\n\n/**\n * Persistence aggregate. Parameterize with a named store shape, or a sparse\n * map for composition (`defineAIPersistence` / `composePersistence`).\n *\n * Default is the sparse bag so untyped / dynamic bags still type-check;\n * prefer {@link ChatTranscriptPersistence} or {@link ChatPersistence} at\n * call sites.\n */\nexport interface AIPersistence<\n TStores extends AIPersistenceStores = AIPersistenceStores,\n> {\n stores: ExactStoreKeys<TStores>\n}\n\n/** {@link AIPersistence} for {@link ChatTranscriptStores}. */\nexport type ChatTranscriptPersistence = AIPersistence<ChatTranscriptStores>\n\n/** {@link AIPersistence} for {@link ChatPersistenceStores}. */\nexport type ChatPersistence = AIPersistence<ChatPersistenceStores>\n\n/** {@link AIPersistence} for {@link ChatWithInterruptsStores}. */\nexport type ChatWithInterruptsPersistence =\n AIPersistence<ChatWithInterruptsStores>\n\ntype StoreKey = keyof AIPersistenceStores\ntype ExactStoreKeys<TStores> =\n Exclude<keyof TStores, StoreKey> extends never\n ? TStores\n : TStores & Record<Exclude<keyof TStores, StoreKey>, never>\n\nexport type AIPersistenceOverrides = {\n [TKey in StoreKey]?: AIPersistenceStores[TKey] | false\n}\n\ntype BaseStoreValue<\n TBase extends AIPersistenceStores,\n TKey extends StoreKey,\n> = TKey extends keyof TBase ? TBase[TKey] : never\n\ntype OverrideStoreValue<\n TOverrides extends AIPersistenceOverrides,\n TKey extends StoreKey,\n> = TKey extends keyof TOverrides ? TOverrides[TKey] : never\n\ntype ResolvedStoreValue<\n TBase extends AIPersistenceStores,\n TOverrides extends AIPersistenceOverrides,\n TKey extends StoreKey,\n> = TKey extends keyof TOverrides\n ?\n | Exclude<OverrideStoreValue<TOverrides, TKey>, false | undefined>\n | (undefined extends OverrideStoreValue<TOverrides, TKey>\n ? Exclude<BaseStoreValue<TBase, TKey>, undefined>\n : never)\n : Exclude<BaseStoreValue<TBase, TKey>, undefined>\n\ntype BaseStoreIsRequired<\n TBase extends AIPersistenceStores,\n TKey extends StoreKey,\n> = TKey extends keyof TBase\n ? object extends Pick<TBase, TKey>\n ? false\n : true\n : false\n\ntype ResolvedStoreIsRequired<\n TBase extends AIPersistenceStores,\n TOverrides extends AIPersistenceOverrides,\n TKey extends StoreKey,\n> = TKey extends keyof TOverrides\n ? false extends OverrideStoreValue<TOverrides, TKey>\n ? false\n : undefined extends OverrideStoreValue<TOverrides, TKey>\n ? BaseStoreIsRequired<TBase, TKey>\n : true\n : BaseStoreIsRequired<TBase, TKey>\n\ntype ResolvedRequiredKeys<\n TBase extends AIPersistenceStores,\n TOverrides extends AIPersistenceOverrides,\n> = {\n [TKey in StoreKey]-?: [ResolvedStoreValue<TBase, TOverrides, TKey>] extends [\n never,\n ]\n ? never\n : ResolvedStoreIsRequired<TBase, TOverrides, TKey> extends true\n ? TKey\n : never\n}[StoreKey]\n\ntype ResolvedOptionalKeys<\n TBase extends AIPersistenceStores,\n TOverrides extends AIPersistenceOverrides,\n> = {\n [TKey in StoreKey]-?: [ResolvedStoreValue<TBase, TOverrides, TKey>] extends [\n never,\n ]\n ? never\n : ResolvedStoreIsRequired<TBase, TOverrides, TKey> extends true\n ? never\n : TKey\n}[StoreKey]\n\ntype Simplify<T> = { [TKey in keyof T]: T[TKey] }\n\nexport type ComposedAIPersistenceStores<\n TBase extends AIPersistenceStores,\n TOverrides extends AIPersistenceOverrides,\n> = Simplify<\n {\n [TKey in ResolvedRequiredKeys<TBase, TOverrides>]: ResolvedStoreValue<\n TBase,\n TOverrides,\n TKey\n >\n } & {\n [TKey in ResolvedOptionalKeys<TBase, TOverrides>]?: ResolvedStoreValue<\n TBase,\n TOverrides,\n TKey\n >\n }\n>\n\nconst storeKeys = [\n 'messages',\n 'runs',\n 'generationRuns',\n 'interrupts',\n 'metadata',\n 'artifacts',\n 'blobs',\n] satisfies Array<StoreKey>\n\nconst storeKeySet = new Set<string>(storeKeys)\n\nfunction assertKnownStoreKeys(stores: object, location: string): void {\n for (const key of Object.keys(stores)) {\n if (!storeKeySet.has(key)) {\n throw new Error(`Unknown AIPersistence ${location} key: ${key}`)\n }\n }\n}\n\nexport function validatePersistenceStoreKeys(persistence: AIPersistence): void {\n assertKnownStoreKeys(persistence.stores, 'store')\n}\n\n/**\n * Chat middleware entrypoint rules:\n * - `messages` is required (chat persistence means a durable transcript)\n * - `interrupts` requires `runs` (interrupt records are run-scoped)\n */\nexport function validateChatPersistenceStores(\n persistence: AIPersistence,\n): void {\n validatePersistenceStoreKeys(persistence)\n if (!persistence.stores.messages) {\n throw new Error('Chat persistence requires stores.messages.')\n }\n if (persistence.stores.interrupts && !persistence.stores.runs) {\n throw new Error('Chat persistence stores.interrupts requires stores.runs.')\n }\n}\n\n/**\n * Generation middleware entrypoint rule: `generationRuns` is required (the\n * generation run lifecycle is keyed on its own `runId`, not a chat conversation\n * `threadId`). When artifact persistence is used, `artifacts` and `blobs` must\n * be provided together.\n */\nexport function validateGenerationPersistenceStores(\n persistence: AIPersistence,\n): void {\n validatePersistenceStoreKeys(persistence)\n const hasArtifacts = persistence.stores.artifacts !== undefined\n const hasBlobs = persistence.stores.blobs !== undefined\n if (hasArtifacts !== hasBlobs) {\n throw new Error(\n 'Generation artifact persistence requires both stores.artifacts and stores.blobs.',\n )\n }\n if (!persistence.stores.generationRuns) {\n throw new Error('Generation persistence requires stores.generationRuns.')\n }\n}\n\n/**\n * Server hydrate entrypoint rule: `messages` is required.\n */\nexport function validateReconstructChatStores(\n persistence: AIPersistence,\n): void {\n validatePersistenceStoreKeys(persistence)\n if (!persistence.stores.messages) {\n throw new Error('reconstructChat requires stores.messages.')\n }\n}\n\n/**\n * Server hydrate entrypoint rule for generation: `generationRuns` is required.\n * The run store resolves the latest generation for a thread (or a specific run\n * id), so a server-authoritative client can hydrate the last generation's\n * status, result, and artifact refs on load.\n */\nexport function validateReconstructGenerationStores(\n persistence: AIPersistence,\n): void {\n validatePersistenceStoreKeys(persistence)\n if (!persistence.stores.generationRuns) {\n throw new Error('reconstructGeneration requires stores.generationRuns.')\n }\n}\n\nexport function defineAIPersistence<TStores extends AIPersistenceStores>(\n persistence: AIPersistence<ExactStoreKeys<TStores>>,\n): AIPersistence<TStores> {\n validatePersistenceStoreKeys(persistence)\n return persistence\n}\n\nexport function composePersistence<\n TBase extends AIPersistenceStores,\n TOverrides extends AIPersistenceOverrides,\n>(\n base: AIPersistence<TBase>,\n config: {\n overrides: ExactStoreKeys<TOverrides>\n },\n): AIPersistence<ComposedAIPersistenceStores<TBase, TOverrides>>\nexport function composePersistence(\n base: AIPersistence,\n config: { overrides: AIPersistenceOverrides },\n): AIPersistence {\n validatePersistenceStoreKeys(base)\n assertKnownStoreKeys(config.overrides, 'override')\n\n const stores: AIPersistenceStores = { ...base.stores }\n for (const key of storeKeys) {\n if (!Object.prototype.hasOwnProperty.call(config.overrides, key)) continue\n const override = config.overrides[key]\n if (override === false) {\n delete stores[key]\n } else if (override !== undefined) {\n setStore(stores, key, override)\n }\n }\n return { stores }\n}\n\nfunction setStore<TKey extends StoreKey>(\n stores: AIPersistenceStores,\n key: TKey,\n value: NonNullable<AIPersistenceStores[TKey]>,\n): void {\n stores[key] = value\n}\n"],"mappings":";;;AAuWA,SAAgB,mBAAmB,OAAmC;CACpE,OAAO;AACT;;AAEA,SAAgB,qBAAqB,OAAuC;CAC1E,OAAO;AACT;;AAEA,SAAgB,oBAAoB,OAAqC;CACvE,OAAO;AACT;;AAEA,SAAgB,yBACd,OACoB;CACpB,OAAO;AACT;;AAEA,SAAgB,oBAAoB,OAAqC;CACvE,OAAO;AACT;;AAEA,SAAgB,gBAAgB,OAA6B;CAC3D,OAAO;AACT;AAsZA,IAAM,YAAY;CAChB;CACA;CACA;CACA;CACA;CACA;CACA;AACF;AAEA,IAAM,cAAc,IAAI,IAAY,SAAS;AAE7C,SAAS,qBAAqB,QAAgB,UAAwB;CACpE,KAAK,MAAM,OAAO,OAAO,KAAK,MAAM,GAClC,IAAI,CAAC,YAAY,IAAI,GAAG,GACtB,MAAM,IAAI,MAAM,yBAAyB,SAAS,QAAQ,KAAK;AAGrE;AAEA,SAAgB,6BAA6B,aAAkC;CAC7E,qBAAqB,YAAY,QAAQ,OAAO;AAClD;;;;;;AAOA,SAAgB,8BACd,aACM;CACN,6BAA6B,WAAW;CACxC,IAAI,CAAC,YAAY,OAAO,UACtB,MAAM,IAAI,MAAM,4CAA4C;CAE9D,IAAI,YAAY,OAAO,cAAc,CAAC,YAAY,OAAO,MACvD,MAAM,IAAI,MAAM,0DAA0D;AAE9E;;;;;;;AAQA,SAAgB,oCACd,aACM;CACN,6BAA6B,WAAW;CAGxC,IAFqB,YAAY,OAAO,cAAc,KAAA,OACrC,YAAY,OAAO,UAAU,KAAA,IAE5C,MAAM,IAAI,MACR,kFACF;CAEF,IAAI,CAAC,YAAY,OAAO,gBACtB,MAAM,IAAI,MAAM,wDAAwD;AAE5E;;;;AAKA,SAAgB,8BACd,aACM;CACN,6BAA6B,WAAW;CACxC,IAAI,CAAC,YAAY,OAAO,UACtB,MAAM,IAAI,MAAM,2CAA2C;AAE/D;;;;;;;AAQA,SAAgB,oCACd,aACM;CACN,6BAA6B,WAAW;CACxC,IAAI,CAAC,YAAY,OAAO,gBACtB,MAAM,IAAI,MAAM,uDAAuD;AAE3E;AAEA,SAAgB,oBACd,aACwB;CACxB,6BAA6B,WAAW;CACxC,OAAO;AACT;AAWA,SAAgB,mBACd,MACA,QACe;CACf,6BAA6B,IAAI;CACjC,qBAAqB,OAAO,WAAW,UAAU;CAEjD,MAAM,SAA8B,EAAE,GAAG,KAAK,OAAO;CACrD,KAAK,MAAM,OAAO,WAAW;EAC3B,IAAI,CAAC,OAAO,UAAU,eAAe,KAAK,OAAO,WAAW,GAAG,GAAG;EAClE,MAAM,WAAW,OAAO,UAAU;EAClC,IAAI,aAAa,OACf,OAAO,OAAO;OACT,IAAI,aAAa,KAAA,GACtB,SAAS,QAAQ,KAAK,QAAQ;CAElC;CACA,OAAO,EAAE,OAAO;AAClB;AAEA,SAAS,SACP,QACA,KACA,OACM;CACN,OAAO,OAAO;AAChB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai-persistence",
3
- "version": "0.5.7",
3
+ "version": "0.6.2",
4
4
  "description": "Composable state persistence for TanStack AI messages, runs, interrupts, metadata, and locks.",
5
5
  "author": "",
6
6
  "license": "MIT",
@@ -43,7 +43,7 @@
43
43
  },
44
44
  "peerDependencies": {
45
45
  "vitest": "^4.1.10",
46
- "@tanstack/ai": "^0.54.0"
46
+ "@tanstack/ai": "^0.57.0"
47
47
  },
48
48
  "peerDependenciesMeta": {
49
49
  "vitest": {
@@ -53,7 +53,7 @@
53
53
  "devDependencies": {
54
54
  "@vitest/coverage-v8": "4.1.10",
55
55
  "vitest": "^4.1.10",
56
- "@tanstack/ai": "0.54.0"
56
+ "@tanstack/ai": "0.57.0"
57
57
  },
58
58
  "scripts": {
59
59
  "build": "vite build",
@@ -73,12 +73,17 @@ Named shapes: `ChatTranscriptPersistence` (floor), `ChatPersistence` (all four).
73
73
  the unparameterized type is the all-optional bag, and `withPersistence` rejects
74
74
  it because `stores.messages` is possibly `undefined`.
75
75
 
76
- ## Authoritative-history contract
76
+ ## Merge incoming messages by id
77
77
 
78
- - **Non-empty `messages`** seed the authoritative history. On finish,
79
- persistence **overwrites** the stored thread with the engine's completed
80
- canonical transcript. Post the complete history, never a delta.
81
- - **Empty `messages`** → middleware **loads** the stored thread and continues.
78
+ `withPersistence` merges incoming `messages` into the stored thread by id.
79
+
80
+ - **Empty `messages`**: load the stored thread and continue.
81
+ - **Non-empty `messages`**: merge by id. The last incoming id that already
82
+ exists in stored is a cutoff. Stored messages after it are dropped. If no
83
+ incoming id is in stored, every stored message stays. Same id: incoming
84
+ wins. New ids and messages with no id are appended.
85
+ - `saveThread` replaces the thread with that merged list. Merge is middleware,
86
+ not the store.
82
87
 
83
88
  ## When state is written
84
89
 
@@ -176,11 +181,16 @@ export async function GET(request: Request) {
176
181
  }
177
182
  ```
178
183
 
179
- Returns `{ messages, activeRun, interrupts }`:
184
+ Returns `{ messages, activeRun, interrupts, page? }`:
185
+
186
+ - `messages`: UI messages for this window
187
+ - `activeRun`: `{ runId }` if a run is still generating (`runs.findActiveRun`)
188
+ - `interrupts`: pending human-in-the-loop state for re-prompt
189
+ - `page`: `{ truncated, cursor }` when the GET has a valid `limit`
180
190
 
181
- - `messages` — UI messages for paint
182
- - `activeRun` — `{ runId }` if a run is still generating (`runs.findActiveRun`)
183
- - `interrupts` — pending human-in-the-loop state for re-prompt
191
+ Paging is opt-in. No `limit` returns the full transcript and can omit `page`.
192
+ `reconstructChat` reads `limit` and `before` from the query. `activeRun` and
193
+ `interrupts` are not paged.
184
194
 
185
195
  **Without `authorize`, anyone who guesses `?threadId=` gets the transcript.**
186
196
 
@@ -192,9 +202,10 @@ activities (image, audio, TTS, video, transcription). Do not fake
192
202
 
193
203
  ## Common mistakes
194
204
 
195
- ### CRITICAL: Posting a message delta as `messages`
205
+ ### CRITICAL: Merge inside `saveThread`
196
206
 
197
- Wipes the stored thread down to that delta. Always send full history or `[]`.
207
+ Merge by id is `withPersistence`. `saveThread` must replace the merged list it
208
+ receives.
198
209
 
199
210
  ### HIGH: Omitting `threadId` / `runId`
200
211
 
@@ -4,7 +4,8 @@ description: >
4
4
  Implement the MessageStore, RunStore, InterruptStore, MetadataStore contracts
5
5
  for @tanstack/ai-persistence against any database. defineAIPersistence,
6
6
  composePersistence overrides, critical invariants (full-replace saveThread,
7
- insert-if-absent createOrResume and interrupt create), authorize thread
7
+ optional loadThread limit/before hint, insert-if-absent createOrResume and
8
+ interrupt create), authorize thread
8
9
  access, runPersistenceConformance testkit. Use whenever you need server
9
10
  persistence — the package ships contracts, not a backend for your database.
10
11
  type: sub-skill
@@ -76,14 +77,27 @@ mistake when writing an adapter.
76
77
  ```ts
77
78
  import type { ModelMessage } from '@tanstack/ai'
78
79
 
80
+ interface MessagePage {
81
+ messages: Array<ModelMessage>
82
+ truncated: boolean
83
+ cursor?: string
84
+ }
85
+
79
86
  interface MessageStore {
80
- loadThread: (threadId: string) => Promise<Array<ModelMessage>>
87
+ loadThread: (
88
+ threadId: string,
89
+ options?: { limit?: number; before?: string },
90
+ ) => Promise<Array<ModelMessage> | MessagePage>
81
91
  saveThread: (threadId: string, messages: Array<ModelMessage>) => Promise<void>
82
92
  }
83
93
  ```
84
94
 
85
- - `loadThread` → `[]` for unknown threads (never `null`).
86
- - `saveThread` is a **full overwrite**, not append. A one-message payload wipes history.
95
+ - Call `loadThread` with only `threadId` and return the full array (`[]` for
96
+ unknown threads, never `null`). Never a `MessagePage`.
97
+ - `limit` and `before` are an optional hydrate hint. Ignore them and return the
98
+ full array, or return a `MessagePage`. `before` is opaque. You mint the cursor.
99
+ - `saveThread` is a **full replace** of the merged list, not append. Merge by
100
+ id is `withPersistence`, not this store.
87
101
 
88
102
  ### `RunStore`
89
103
 
@@ -378,6 +392,9 @@ export const messages = defineMessageStore({
378
392
  })
379
393
  ```
380
394
 
395
+ This example ignores `limit` / `before` and returns the full array. That is
396
+ valid. `reconstructChat` slices a full array after UI conversion.
397
+
381
398
  For durable DBs, preserve the same semantics with upserts / full-row replace.
382
399
 
383
400
  ## Adopt part of it
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ export {
15
15
  } from './types'
16
16
  export type {
17
17
  MessageStore,
18
+ MessagePage,
18
19
  RunStatus,
19
20
  TerminalRunStatus,
20
21
  RunRecord,
package/src/memory.ts CHANGED
@@ -41,7 +41,10 @@ const compareUtf8Bytes = (left: string, right: string): number => {
41
41
 
42
42
  class MemoryMessageStore implements MessageStore {
43
43
  private readonly threads = new Map<string, Array<ModelMessage>>()
44
- loadThread(threadId: string): Promise<Array<ModelMessage>> {
44
+ loadThread(
45
+ threadId: string,
46
+ _options?: { limit?: number; before?: string },
47
+ ): Promise<Array<ModelMessage>> {
45
48
  return Promise.resolve(this.threads.get(threadId)?.slice() ?? [])
46
49
  }
47
50
  saveThread(threadId: string, messages: Array<ModelMessage>): Promise<void> {
package/src/middleware.ts CHANGED
@@ -47,6 +47,7 @@ import type {
47
47
  GenerationMiddleware,
48
48
  GenerationMiddlewareContext,
49
49
  Interrupt,
50
+ ModelMessage,
50
51
  PendingInterruptResumeRecord,
51
52
  PersistedArtifactActivity,
52
53
  PersistedArtifactRef,
@@ -66,6 +67,7 @@ import type {
66
67
  ChatTranscriptStores,
67
68
  InterruptCommitEntry,
68
69
  InterruptRecord,
70
+ MessagePage,
69
71
  RunStore,
70
72
  } from './types'
71
73
  import { artifactBlobKey } from './retrieve'
@@ -1907,26 +1909,78 @@ function detachableRun(ctx: ChatMiddlewareContext): boolean {
1907
1909
  // Chat middleware
1908
1910
  // ---------------------------------------------------------------------------
1909
1911
 
1910
- /**
1911
- * Chat-only **state** persistence middleware. Provides durable transcript,
1912
- * run records, and interrupts for `chat()`. Does **not** provide locks —
1913
- * use `withLocks` from `@tanstack/ai` for multi-instance coordination.
1914
- *
1915
- * This middleware never mutates the chunk stream; delivery durability
1916
- * (replaying a disconnected/reloaded stream) is a separate transport-layer
1917
- * concern (see the resumable-streams docs).
1918
- *
1919
- * Requires `stores.messages`. When `stores.interrupts` is present,
1920
- * `stores.runs` is also required.
1921
- *
1922
- * ⚠️ AUTHORITATIVE-HISTORY CONTRACT: when a request carries a non-empty
1923
- * `messages` array it is treated as the FULL conversation history and, on
1924
- * finish, **overwrites** the entire stored thread. Post only the complete
1925
- * transcript, never a delta — sending just the newest message(s) will replace
1926
- * (and thereby destroy) the stored thread. To continue a stored thread without
1927
- * resending history, pass an empty `messages` array and the stored transcript
1928
- * is loaded and used.
1929
- */
1912
+ function threadMessages(
1913
+ loaded: Array<ModelMessage> | MessagePage,
1914
+ ): Array<ModelMessage> {
1915
+ return Array.isArray(loaded) ? loaded : loaded.messages
1916
+ }
1917
+
1918
+ // Empty incoming keeps stored. Non-empty: the last incoming id that already
1919
+ // exists in stored is a cutoff (reload drops the old assistant after that
1920
+ // user). Same id is replaced in place. New ids and messages with no id are
1921
+ // appended.
1922
+ function mergeStoredMessages(
1923
+ stored: ReadonlyArray<ModelMessage>,
1924
+ incoming: ReadonlyArray<ModelMessage>,
1925
+ ) {
1926
+ if (incoming.length === 0) {
1927
+ return stored.slice()
1928
+ }
1929
+
1930
+ let cutoff = stored.length
1931
+ for (let index = incoming.length - 1; index >= 0; index--) {
1932
+ const id = incoming[index]?.id
1933
+ if (id === undefined) continue
1934
+ const storedIndex = stored.findIndex((message) => message.id === id)
1935
+ if (storedIndex >= 0) {
1936
+ cutoff = storedIndex + 1
1937
+ break
1938
+ }
1939
+ }
1940
+ const prefix = stored.slice(0, cutoff)
1941
+
1942
+ const incomingById = new Map<string, ModelMessage>()
1943
+ for (const message of incoming) {
1944
+ const id = message.id
1945
+ if (id) incomingById.set(id, message)
1946
+ }
1947
+
1948
+ const storedIds = new Set<string>()
1949
+ const merged: Array<ModelMessage> = []
1950
+ for (const message of prefix) {
1951
+ const id = message.id
1952
+ if (id) {
1953
+ storedIds.add(id)
1954
+ merged.push(incomingById.get(id) ?? message)
1955
+ continue
1956
+ }
1957
+ merged.push(message)
1958
+ }
1959
+
1960
+ for (let index = 0; index < incoming.length; index++) {
1961
+ const message = incoming[index]
1962
+ if (!message) continue
1963
+ const id = message.id
1964
+ if (id && storedIds.has(id)) continue
1965
+ // Attach/reload can post the stored transcript again with no ids. Keep the
1966
+ // prefix row instead of appending a second copy of the same turn.
1967
+ if (!id) {
1968
+ const existing = merged[index]
1969
+ if (
1970
+ existing &&
1971
+ existing.id === undefined &&
1972
+ existing.role === message.role &&
1973
+ existing.content === message.content
1974
+ ) {
1975
+ continue
1976
+ }
1977
+ }
1978
+ merged.push(message)
1979
+ }
1980
+
1981
+ return merged
1982
+ }
1983
+
1930
1984
  export interface WithPersistenceOptions {
1931
1985
  /**
1932
1986
  * Also persist a throttled snapshot of the in-progress assistant reply while
@@ -1945,6 +1999,24 @@ export interface WithPersistenceOptions {
1945
1999
  }
1946
2000
 
1947
2001
  /**
2002
+ * Chat-only **state** persistence middleware. Provides durable transcript,
2003
+ * run records, and interrupts for `chat()`. Does **not** provide locks —
2004
+ * use `withLocks` from `@tanstack/ai` for multi-instance coordination.
2005
+ *
2006
+ * This middleware never mutates the chunk stream; delivery durability
2007
+ * (replaying a disconnected/reloaded stream) is a separate transport-layer
2008
+ * concern (see the resumable-streams docs).
2009
+ *
2010
+ * Requires `stores.messages`. When `stores.interrupts` is present,
2011
+ * `stores.runs` is also required.
2012
+ *
2013
+ * Incoming `messages` merge into the stored thread by id. An empty list loads
2014
+ * the stored thread. The last incoming id that already exists in stored is a
2015
+ * cutoff; stored messages after it are dropped (reload). If no incoming id is
2016
+ * in stored, every stored message stays. Same id: incoming wins. New ids and
2017
+ * messages with no id are appended. `saveThread` still replaces the thread
2018
+ * with that merged list.
2019
+ *
1948
2020
  * @param persistence - Must satisfy {@link ChatTranscriptStores} (messages
1949
2021
  * required). Known-absent `messages` or `interrupts` without `runs` fail at
1950
2022
  * compile time; fully dynamic bags are checked at runtime.
@@ -2019,11 +2091,12 @@ export function withPersistence<TStores extends ChatTranscriptStores>(
2019
2091
  // it behaves exactly as before. See `PendingTurnCapability`.
2020
2092
  providePendingTurn(ctx, {
2021
2093
  snapshot: async () => {
2022
- const stored = await messageStore.loadThread(ctx.threadId)
2023
- // The SAME rule `onConfig` applies when it merges. Kept here, in the
2024
- // owner, because `saveThread` REPLACES the thread: a caller that stored
2025
- // only the newly-sent list would delete the history.
2026
- const list = ctx.messages.length > 0 ? [...ctx.messages] : stored
2094
+ const stored = threadMessages(
2095
+ await messageStore.loadThread(ctx.threadId),
2096
+ )
2097
+ // Same merge as onConfig. saveThread replaces the thread, so a short
2098
+ // incoming list must not drop stored extras.
2099
+ const list = mergeStoredMessages(stored, ctx.messages)
2027
2100
  await messageStore.saveThread(ctx.threadId, list)
2028
2101
  },
2029
2102
  })
@@ -2088,8 +2161,10 @@ export function withPersistence<TStores extends ChatTranscriptStores>(
2088
2161
  if (state && storedUsage) state.usage = storedUsage
2089
2162
  if (!state?.merged) {
2090
2163
  if (state) state.merged = true
2091
- const stored = await messageStore.loadThread(ctx.threadId)
2092
- patch.messages = config.messages.length > 0 ? config.messages : stored
2164
+ const stored = threadMessages(
2165
+ await messageStore.loadThread(ctx.threadId),
2166
+ )
2167
+ patch.messages = mergeStoredMessages(stored, config.messages)
2093
2168
  }
2094
2169
 
2095
2170
  return Object.keys(patch).length > 0 ? patch : undefined