@tanstack/ai-persistence
Version:
Composable state persistence for TanStack AI messages, runs, interrupts, metadata, and locks.
1,149 lines • 50.2 kB
TypeScript
import { ActivityRecord, ModelMessage, MetadataStore, PersistedArtifactRef, RunStatus, RunStore, Scope, TokenUsage } from '@tanstack/ai';
export type { ActivityRecord, MetadataStore, Scope };
/**
* One page of a thread from {@link MessageStore.loadThread} when the caller
* passed a paging hint.
*
* Middleware omits the hint and always gets a full `Array<ModelMessage>`,
* never this shape.
*
* `truncated: true` requires `cursor`. Without a cursor the client cannot
* request the next older window, so `reconstructChat` treats that page as
* complete.
*/
export type MessagePage = {
messages: Array<ModelMessage>;
truncated: false;
cursor?: never;
} | {
messages: Array<ModelMessage>;
truncated: true;
cursor: string;
};
/**
* 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 stored transcript for `threadId` ({@link Scope.threadId}),
* in insertion order.
*
* Call with only `threadId` (middleware, `onStart`, `onFinish`) and this
* MUST return the full transcript as an `Array<ModelMessage>`. Never a
* {@link MessagePage}.
*
* `options.limit` and `options.before` are an optional paging hint for
* hydrate. Adapters may ignore them and still return the full array. An
* adapter that pages returns a {@link MessagePage}.
*
* 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>>;
(threadId: string, options: {
limit?: number;
before?: string;
}): Promise<Array<ModelMessage> | MessagePage>;
};
/**
* 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>;
}
/**
* Durable sidecar for frontend-only AG-UI activity.
*
* Activity is never a {@link ModelMessage} and must not live in
* {@link MessageStore}. This store is optional: backends that omit it keep
* today's behavior (activity is not durable on the server).
*
* `saveActivities` always receives and persists the **complete, authoritative**
* activity list — it is an overwrite, never an append.
*/
export interface ActivityStore {
/**
* Return the stored activity rows for `threadId`, in the order they were
* last saved.
*
* INVARIANT: returns an empty array (never `null`/`undefined`) for a thread
* that was never saved. Callers treat `[]` as "no activity".
*/
loadActivities: (threadId: string) => Promise<Array<ActivityRecord>>;
/**
* Overwrite the stored activity rows for `threadId` with `activities`.
*
* INVARIANT: this is a full replace. `activities` is the complete
* authoritative list; the previous contents are discarded.
*/
saveActivities: (threadId: string, activities: Array<ActivityRecord>) => Promise<void>;
}
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>>;
}
/** Type a {@link MessageStore} implementation inline. */
export declare function defineMessageStore(store: MessageStore): MessageStore;
/** Type an {@link ActivityStore} implementation inline. */
export declare function defineActivityStore(store: ActivityStore): ActivityStore;
/** Type an {@link InterruptStore} implementation inline. */
export declare function defineInterruptStore(store: InterruptStore): InterruptStore;
/** Type a {@link MetadataStore} implementation inline. */
export declare function defineMetadataStore(store: MetadataStore): MetadataStore;
/** Type a {@link GenerationRunStore} implementation inline. */
export declare function defineGenerationRunStore(store: GenerationRunStore): GenerationRunStore;
/** Type an {@link ArtifactStore} implementation inline. */
export declare function defineArtifactStore(store: ArtifactStore): ArtifactStore;
/** Type a {@link BlobStore} implementation inline. */
export declare function defineBlobStore(store: BlobStore): BlobStore;
/**
* 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. `storageKey` can put
* bytes anywhere, so a reader cannot recompute the path; it has to be
* remembered.
*/
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>;
}
/**
* One record of a session log. `type` names the kind of record. The other
* fields are JSON. The store does not read records: the writer owns the
* `type` names.
*/
export interface LogRecord {
type: string;
[key: string]: unknown;
}
/** A {@link LogRecord} and its position in the log. */
export interface LogEntry {
/** The position of the record. The first record of a log is at 1. */
seq: number;
record: LogRecord;
}
/**
* The epoch of a log that no host ever acquired. A writer that never calls
* {@link LogStore.acquireOwnership} writes with this epoch.
*/
export declare const NO_OWNER_EPOCH = 0;
/**
* The error {@link LogStore.append} rejects with when `seq` is not the next
* free position of the log. It means that another writer appended first.
* Nothing of the rejected batch is written.
*/
export declare class LogConflictError extends Error {
readonly logId: string;
readonly seq: number;
constructor(logId: string, seq: number);
}
/**
* The error that a fenced write ({@link LogStore.append},
* {@link LogStore.putSnapshot}, {@link LogStore.truncate},
* {@link LogStore.pinToWorkers}) rejects with when `epoch` does not give the
* writer the log. It means that another host acquired the log, that the
* owner released it, that the log was deleted, or that the writer did not
* acquire a log that needs an owner. Nothing is written. Stop the work on the
* log.
*/
export declare class OwnershipLostError extends Error {
readonly logId: string;
readonly epoch: number;
constructor(logId: string, epoch: number);
}
/**
* The error {@link LogStore.read} rejects with when the query starts before
* the first record that the log keeps. A truncate dropped the records before
* `firstSeq`. Load the snapshot, then read after its `seq`.
*/
export declare class LogTruncatedError extends Error {
readonly logId: string;
readonly firstSeq: number;
constructor(logId: string, firstSeq: number);
}
/**
* The result of {@link LogStore.offerInput}:
*
* - `'offered'`: the store keeps the input as a pending offer.
* - `'duplicate'`: the id was offered before with an equal record.
* - `'conflict'`: the id was offered before with a different record.
* - `'deleted'`: `deleteLog` deleted the log, and no host acquired it since.
* Nothing is stored.
*/
export type OfferResult = 'offered' | 'duplicate' | 'conflict' | 'deleted';
/** A pending input of a log, from {@link LogStore.listOfferedInputs}. */
export interface OfferedInput {
inputId: string;
record: LogRecord;
/** Epoch milliseconds, from the store clock. */
offeredAt: number;
}
/** A log that has work and no live owner, from {@link LogStore.listLogsWithoutOwner}. */
export interface OrphanedLog {
logId: string;
/** The name of the setup that runs the log, so a worker can open it. */
harness: string;
/**
* Why the log has work:
*
* - `'expired'`: the ownership expired and the owner did not release it.
* - `'offered'`: the log has pending offers and no live owner.
* - `'wake'`: the owner released the log with a wake time that is due.
*/
reason: 'expired' | 'offered' | 'wake';
}
/** The saved state of a log, from {@link LogStore.getSnapshot}. */
export interface LogSnapshot {
/** The position of the last record that `state` includes. */
seq: number;
/** The version of the `state` shape. The writer owns the version names. */
version: string;
/** JSON state. The store does not read it. */
state: unknown;
}
/**
* Durable append-only log of a session tree. A host keeps the events, the
* transcript, the inputs, and the tool steps of the tree in it. The store also
* keeps one ownership record per log, the pending inputs (the input lane), and
* the latest snapshot.
*
* The log has one writer at a time: the owner. A host gets the log with
* `acquireOwnership`, and each fenced write carries the epoch that `acquireOwnership` returned.
* When another host acquired the log, the epoch is old and the write fails.
* `append` is also a compare-and-append: the writer names the position of the
* first record, and the store refuses the batch when that position is taken.
*
* The store clock decides expiry. Callers pass `ttlMs` and `wakeInMs`, never
* timestamps. Records are JSON: a store keeps copies, never the objects of
* the caller.
*/
export interface LogStore {
/**
* Write `records` at positions `seq`, `seq + 1`, and so on, and consume the
* pending offers in `consumes` in the same write.
*
* The store checks in this order, and the first check that fails rejects:
*
* 1. The fence. On a log that a host acquired, `epoch` must be the current
* epoch, and the owner must not have released the log. On a log that no
* host acquired, `epoch` must be {@link NO_OWNER_EPOCH}. A deleted log
* refuses every epoch until the next `acquireOwnership`. Else reject with
* {@link OwnershipLostError}.
* 2. `seq` must be the next free position (the last position plus 1, or 1
* for a new log). Else reject with {@link LogConflictError}.
* 3. Each id in `consumes` must be a pending offer. Else reject with a
* `TypeError`.
* 4. Each record must become JSON. Else reject with the error of the copy.
*
* INVARIANT (atomic): the store writes the whole batch and consumes all of
* `consumes`, or it changes nothing. A crash never leaves a part of a batch.
*
* An empty batch with no `consumes` resolves, writes nothing, and checks
* nothing.
*/
append: (batch: {
logId: string;
seq: number;
epoch: number;
records: ReadonlyArray<LogRecord>;
consumes?: ReadonlyArray<string>;
}) => Promise<void>;
/**
* The entries of `logId` with `seq` greater than `after` (default 0), in
* ascending order, at most `limit` of them. `limit: 0` gives `[]`.
*
* If a truncate dropped the record at position `after + 1`, reject with
* {@link LogTruncatedError}.
*
* INVARIANT: a log that has no records gives `[]`. The returned records are
* copies: a change to them does not change the log.
*/
read: (query: {
logId: string;
after?: number;
limit?: number;
}) => Promise<Array<LogEntry>>;
/**
* Call `listener` after each successful `append` to `logId`, each `offerInput`
* that returns `'offered'`, and each `deleteLog` of `logId`. The calls come
* from every store instance on the same backend (other machines). A store
* that cannot push must poll. A listener that throws is logged, and the
* write still succeeds. Returns a function that stops the calls.
*/
subscribe: (watch: {
logId: string;
listener: () => void;
}) => () => void;
/**
* Get the log for `ownerId` for `ttlMs` milliseconds of the store clock.
*
* - Returns `null` when another owner holds the log (not released, and not
* expired).
* - Returns `null` when the log has an affinity that is not
* `claim.affinity`, and the host of that affinity is alive (see
* `markHostAlive`). The pin of a host with no live heartbeat does not count.
* - When `ownerId` already holds the log, extends the expiry and returns
* the same epoch.
* - Else increments the epoch, stores `harness`, clears the wake time,
* opens a deleted log again, and returns the new epoch. The epoch never
* goes down, also after `releaseOwnership` and `deleteLog`.
* - When the log had another owner whose ownership expired with no
* `releaseOwnership`, the result has `expired: true`: that owner stopped.
*
* Rejects with a `RangeError` when `ttlMs` is not more than 0.
*/
acquireOwnership: (claim: {
logId: string;
harness: string;
ownerId: string;
ttlMs: number;
affinity?: string;
}) => Promise<{
epoch: number;
expired?: boolean;
} | null>;
/**
* Extend the ownership to `ttlMs` milliseconds from now. Returns `true` only
* when the log is not released, `epoch` is the current epoch, and `ownerId`
* is the owner. A renew after the expiry succeeds when no other host
* acquired the log in between.
*
* Rejects with a `RangeError` when `ttlMs` is not more than 0.
*/
renewOwnership: (claim: {
logId: string;
ownerId: string;
epoch: number;
ttlMs: number;
}) => Promise<boolean>;
/**
* Give up the log. With a matching `epoch` and `ownerId`, mark the log
* released, and set a wake time `wakeInMs` from now when `wakeInMs` is
* given. Any other call does nothing.
*
* Rejects with a `RangeError` when `wakeInMs` is less than 0.
*/
releaseOwnership: (claim: {
logId: string;
ownerId: string;
epoch: number;
wakeInMs?: number;
}) => Promise<void>;
/**
* Pin the log to the workers with `affinity`, or unpin it with `null`.
* Only the owner pins: `epoch` must be the current epoch of an acquired
* log that is not released. Else reject with {@link OwnershipLostError},
* also for {@link NO_OWNER_EPOCH}.
*/
pinToWorkers: (pin: {
logId: string;
epoch: number;
affinity: string | null;
}) => Promise<void>;
/**
* Mark the host `hostId` alive for `ttlMs` milliseconds of the store
* clock. `hostId` is the affinity of its pins. A pin counts only while its
* host is alive, so the logs of a host that stopped go to other hosts.
*
* Rejects with a `RangeError` when `ttlMs` is not more than 0.
*/
markHostAlive: (beat: {
hostId: string;
ttlMs: number;
}) => Promise<void>;
/**
* The logs that have work and no live owner, at most `limit` of them. A log
* is listed for the first reason that applies:
*
* 1. `'expired'`: not released, and the expiry is due.
* 2. `'offered'`: pending offers, and no live owner.
* 3. `'wake'`: released, and the wake time is due.
*
* Only logs of `harnesses` are listed, when `harnesses` is given. A log with
* an affinity is listed only when `affinity` is equal, or the host of the
* affinity is not alive (see `markHostAlive`). The log that is due
* first comes first. `limit: 0` gives `[]`.
*/
listLogsWithoutOwner: (query: {
limit: number;
harnesses?: ReadonlyArray<string>;
affinity?: string;
}) => Promise<Array<OrphanedLog>>;
/**
* Keep `record` as a pending input of the log. Any machine can offer. Only
* the owner consumes, with `append({ consumes })`.
*
* - A deleted log returns `'deleted'` and stores nothing, until the next
* `acquireOwnership`.
* - An `inputId` that was offered before (pending or consumed) returns
* `'duplicate'` when the JSON of the record is equal, else `'conflict'`.
* Nothing changes.
* - A new `inputId` is stored with the store clock as `offeredAt`, calls
* the listeners, and returns `'offered'`. `harness` names the harness of
* the log only when the log has no owner and no earlier offer: the
* harness of the owner, else of the first offer, stays.
*
* Rejects, and stores nothing, when the record cannot become JSON.
*/
offerInput: (input: {
logId: string;
harness: string;
inputId: string;
record: LogRecord;
}) => Promise<OfferResult>;
/** The pending offers of `logId`, in offer order. */
listOfferedInputs: (query: {
logId: string;
}) => Promise<Array<OfferedInput>>;
/** The latest snapshot of `logId`, or `null` when the log has none. */
getSnapshot: (query: {
logId: string;
}) => Promise<LogSnapshot | null>;
/**
* Replace the snapshot of the log. Fenced like `pinToWorkers`: only the
* owner of an acquired log writes it. Rejects with a `RangeError`
* unless `firstSeq - 1 <= seq <= head`. `head` is the last position of the
* log. `firstSeq` is the first kept position after a `truncate`, or 1. A
* snapshot before `firstSeq - 1` needs records that a truncate dropped.
*/
putSnapshot: (snapshot: {
logId: string;
epoch: number;
} & LogSnapshot) => Promise<void>;
/**
* Drop the records with `seq` less than `beforeSeq`. The positions keep
* counting, so the next `append` is still at the last position plus 1.
* Fenced like `pinToWorkers`: only the owner of an acquired log truncates.
* Rejects with a `RangeError` unless a snapshot
* has a `seq` of `beforeSeq - 1` or more.
*/
truncate: (cut: {
logId: string;
epoch: number;
beforeSeq: number;
}) => Promise<void>;
/**
* Remove the records, the offers, the snapshot, the truncation point, the
* affinity, and the wake time of `logId`, and call the listeners. The epoch
* counter stays. Every fenced write to the log fails until the next
* `acquireOwnership`, also with {@link NO_OWNER_EPOCH}, so a late write cannot bring
* the deleted records back. An `offerInput` returns `'deleted'`.
*/
deleteLog: (target: {
logId: string;
}) => Promise<void>;
}
/** Type a {@link LogStore} implementation inline. */
export declare function defineLogStore(store: LogStore): LogStore;
/**
* A user who can open a session, and the role of that user in it. The
* `owner` is the user who opened the session. The host decides what a
* `member`, a `viewer`, and an `operator` (a person who takes over from an
* agent) can do.
*/
export interface SessionMember {
userId: string;
role: 'owner' | 'member' | 'viewer' | 'operator';
}
/**
* The key of one session index entry. Without `tenantId`, the key is in the
* default tenant.
*/
export interface SessionIndexKey {
tenantId?: string;
threadId: string;
}
/**
* One entry of the session index: what a list of sessions shows for one
* thread. The index holds no messages. The thread data stays in the stores
* that hold it.
*
* @property createdAt - Epoch ms when the thread was first opened.
* @property updatedAt - Epoch ms of the last change. {@link SessionIndexStore.list}
* sorts by it.
*/
export interface SessionIndexEntry<TMetadata = Record<string, unknown>> {
/**
* The tenant of the thread. Without it, the entry is in the default tenant.
* The tenant and `threadId` together are the key, so one thread id in two
* tenants is two entries.
*/
tenantId?: string;
threadId: string;
/**
* The users who can open the thread. The owner is the member with the role
* `owner`. Empty when the thread has no owner: a host without access rules
* opened it without a user. A subagent child keeps it empty: access to a
* child goes through its top-level thread.
*/
members: Array<SessionMember>;
/** The name of the setup that runs the thread. */
harness?: string;
title?: string;
/**
* The thread that started this one. A subagent child has the thread id
* `subagent:<runId>` and the thread of its parent here.
*/
parentThreadId?: string;
/** The tool call in the parent thread that started this thread. */
parentToolCallId?: string;
createdAt: number;
updatedAt: number;
pinned?: boolean;
/**
* `true` when the thread is archived. {@link SessionIndexStore.list} leaves
* archived entries out unless `withArchived` is `true`. The thread and its
* data stay, and it still opens.
*/
archived?: boolean;
/** Token totals of the thread. */
usage?: {
/** Model calls. */
turns: number;
promptTokens: number;
completionTokens: number;
totalTokens: number;
/** Input tokens the provider read from its prompt cache. */
cachedTokens: number;
/** Input tokens the provider wrote to its prompt cache. */
cacheWriteTokens: number;
/** Cost in USD, when the host counts it. */
cost?: number;
};
/** Data the app owns, for example the context items a user pinned. */
metadata?: TMetadata;
}
/** Options for {@link SessionIndexStore.list}. */
export interface SessionIndexListOptions {
/**
* The tenant to list. Without it, the list has only the default tenant. A
* list never has the entries of another tenant.
*/
tenantId?: string;
/**
* Only the entries whose `members` have this user id, with any role. An
* entry with no `members` (a subagent child) never matches.
*/
memberId?: string;
/** The most entries in one page. Without it, the page has all entries. */
limit?: number;
/** The `cursor` of the previous page. */
cursor?: string;
/**
* Only the entries whose `parentThreadId` is this thread. `null`: only the
* entries with no parent (the top-level sessions).
*/
parentThreadId?: string | null;
/**
* Only the entries whose `title` contains this text, without case. An entry
* without a title never matches.
*/
search?: string;
/** Only the entries whose `harness` is exactly this name. */
harness?: string;
/**
* Only the entries whose `metadata` has each key with exactly this string
* value.
*/
metadata?: Record<string, string>;
/**
* `true`: the list has the archived entries too. Default: it leaves out the
* entries with `archived: true`.
*/
withArchived?: boolean;
}
/**
* One page of a {@link SessionIndexStore.list} scan.
*
* @property cursor - The token for the next page. Only when `truncated`.
* @property truncated - `true` when more entries match after this page.
*/
export interface SessionIndexPage {
entries: Array<SessionIndexEntry>;
cursor?: string;
truncated?: boolean;
}
/**
* Durable index of sessions, one entry per tenant and thread. The key is
* `(tenantId ?? '', threadId)`: a key without `tenantId` is in the default
* tenant, and one thread id in two tenants is two entries.
*/
export interface SessionIndexStore {
/** The entry for `key`, or `undefined`. */
get: (key: SessionIndexKey) => Promise<SessionIndexEntry | undefined>;
/**
* The entry for `key` with its revision, or `undefined` when it is absent.
* A write that changes the entry gives it a new revision. A revision may
* name the content (a content-hash ETag, as in S3 or Netlify Blobs), so the
* same entry written again, also after a `delete`, can get the same
* revision back.
*/
getVersioned: (key: SessionIndexKey) => Promise<{
entry: SessionIndexEntry;
revision: string;
} | undefined>;
/**
* Write the entry for the tenant and thread of `entry`.
*
* INVARIANT (full replace): a second upsert for the same key replaces the
* whole entry. A field that the new entry does not have is gone. To change
* one field, read the entry and write a changed copy with `upsertIf`.
*/
upsert: (entry: SessionIndexEntry) => Promise<void>;
/**
* Write `entry` only when the current revision of its key is `revision`
* (`null` means "only when absent"). Returns the new revision, or
* `{ ok: false, reason: 'conflict' }` when someone else wrote first. With
* `getVersioned`, hosts on several machines do not lose each other's
* changes to one entry. A full replace, the same as `upsert`.
*/
upsertIf: (input: {
entry: SessionIndexEntry;
revision: string | null;
}) => Promise<{
ok: true;
revision: string;
} | {
ok: false;
reason: 'conflict';
}>;
/**
* The entries of one tenant that match the filters, newest `updatedAt`
* first. Entries with the same `updatedAt` are in `threadId` order. Pinned
* entries get no special place. All filters must match. Archived entries
* are left out unless `withArchived` is `true`.
*
* CURSOR SEMANTICS: when `limit` is given and more entries match, the page
* is `truncated: true` with a `cursor`. Pass that `cursor` back, with the
* same filters, to get the entries that follow the last entry of the page.
* An entry that changes between two pages can move in the order.
*/
list: (options?: SessionIndexListOptions) => Promise<SessionIndexPage>;
/**
* Remove the entry for `key`. A hard delete: after it, `get` and
* `getVersioned` return `undefined`, and `upsertIf` with revision `null`
* writes a new entry. A no-op for an unknown key.
*
* This removes the entry only. The messages, the log, and the other data of
* the thread stay. The stores that hold that data remove it.
*/
delete: (key: SessionIndexKey) => Promise<void>;
}
/** Type a {@link SessionIndexStore} implementation inline. */
export declare function defineSessionIndexStore(store: SessionIndexStore): SessionIndexStore;
/**
* 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;
activities?: ActivityStore;
runs?: RunStore;
interrupts?: InterruptStore;
metadata?: MetadataStore;
generationRuns?: GenerationRunStore;
artifacts?: ArtifactStore;
blobs?: BlobStore;
/** Append-only session log. */
log?: LogStore;
/** The session index. */
sessions?: SessionIndexStore;
}
/**
* 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;
activities?: ActivityStore;
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;
activities?: ActivityStore;
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>;
}>;
export declare function validatePersistenceStoreKeys(persistence: AIPersistence): void;
/**
* Chat middleware entrypoint rules:
* - `messages` is required (chat persistence means a durable transcript)
* - `interrupts` requires `runs` (interrupt records are run-scoped)
*/
export declare function validateChatPersistenceStores(persistence: AIPersistence): void;
/**
* 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 declare function validateGenerationPersistenceStores(persistence: AIPersistence): void;
/**
* Server hydrate entrypoint rule: `messages` is required.
*/
export declare function validateReconstructChatStores(persistence: AIPersistence): void;
/**
* 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 declare function validateReconstructGenerationStores(persistence: AIPersistence): void;
export declare function defineAIPersistence<TStores extends AIPersistenceStores>(persistence: AIPersistence<ExactStoreKeys<TStores>>): AIPersistence<TStores>;
export declare function composePersistence<TBase extends AIPersistenceStores, TOverrides extends AIPersistenceOverrides>(base: AIPersistence<TBase>, config: {
overrides: ExactStoreKeys<TOverrides>;
}): AIPersistence<ComposedAIPersistenceStores<TBase, TOverrides>>;