@tanstack/ai-persistence
Version:
Composable state persistence for TanStack AI messages, runs, interrupts, metadata, and locks.
1,356 lines (1,286 loc) • 44.3 kB
text/typescript
import {
LogConflictError,
LogTruncatedError,
OwnershipLostError,
NO_OWNER_EPOCH,
defineAIPersistence,
} from './types'
import { resolveBlobRange } from './blob-range'
import type { ActivityRecord, ModelMessage } from '@tanstack/ai'
import type {
ActivityStore,
ArtifactRecord,
ArtifactStore,
BlobBody,
BlobGetOptions,
BlobListOptions,
BlobObject,
BlobPutOptions,
BlobRange,
BlobRecord,
BlobStore,
GenerationRunRecord,
GenerationRunStore,
InterruptCommitEntry,
InterruptRecord,
InterruptStore,
LogRecord,
LogSnapshot,
LogStore,
OrphanedLog,
MessageStore,
MetadataStore,
RunRecord,
RunStore,
SessionIndexEntry,
SessionIndexKey,
SessionIndexListOptions,
SessionIndexStore,
} from './types'
const compareUtf8Bytes = (left: string, right: string): number => {
const leftBytes = new TextEncoder().encode(left)
const rightBytes = new TextEncoder().encode(right)
const length = Math.min(leftBytes.length, rightBytes.length)
for (let index = 0; index < length; index++) {
const leftByte = leftBytes[index]
const rightByte = rightBytes[index]
if (leftByte !== rightByte) {
return (leftByte ?? 0) - (rightByte ?? 0)
}
}
return leftBytes.length - rightBytes.length
}
class MemoryMessageStore implements MessageStore {
private readonly threads = new Map<string, Array<ModelMessage>>()
loadThread(
threadId: string,
_options?: { limit?: number; before?: string },
): Promise<Array<ModelMessage>> {
return Promise.resolve(this.threads.get(threadId)?.slice() ?? [])
}
saveThread(threadId: string, messages: Array<ModelMessage>): Promise<void> {
this.threads.set(threadId, messages.slice())
return Promise.resolve()
}
}
class MemoryActivityStore implements ActivityStore {
private readonly threads = new Map<string, Array<ActivityRecord>>()
loadActivities(threadId: string): Promise<Array<ActivityRecord>> {
return Promise.resolve(this.threads.get(threadId)?.slice() ?? [])
}
saveActivities(
threadId: string,
activities: Array<ActivityRecord>,
): Promise<void> {
this.threads.set(threadId, activities.slice())
return Promise.resolve()
}
}
class MemoryRunStore implements RunStore {
private readonly runs = new Map<string, RunRecord>()
createOrResume(
input: Parameters<RunStore['createOrResume']>[0],
): Promise<RunRecord> {
const existing = this.runs.get(input.runId)
if (existing) return Promise.resolve(existing)
const record: RunRecord = {
runId: input.runId,
threadId: input.threadId,
status: input.status ?? 'running',
startedAt: input.startedAt,
...(input.parentRunId !== undefined
? { parentRunId: input.parentRunId }
: {}),
...(input.subagentRunId !== undefined
? { subagentRunId: input.subagentRunId }
: {}),
...(input.name !== undefined ? { name: input.name } : {}),
...(input.kind !== undefined ? { kind: input.kind } : {}),
...(input.activity !== undefined ? { activity: input.activity } : {}),
...(input.agent !== undefined ? { agent: input.agent } : {}),
...(input.principal !== undefined ? { principal: input.principal } : {}),
}
this.runs.set(record.runId, record)
return Promise.resolve(record)
}
update(
runId: string,
patch: Parameters<RunStore['update']>[1],
): Promise<void> {
const existing = this.runs.get(runId)
if (existing) this.runs.set(runId, { ...existing, ...patch })
return Promise.resolve()
}
get(runId: string): Promise<RunRecord | null> {
return Promise.resolve(this.runs.get(runId) ?? null)
}
findActiveRun(threadId: string): Promise<RunRecord | null> {
const active = [...this.runs.values()]
.filter((run) => run.threadId === threadId && run.status === 'running')
.sort((a, b) => b.startedAt - a.startedAt)
return Promise.resolve(active[0] ?? null)
}
listByThread(threadId: string): Promise<Array<RunRecord>> {
const matching = [...this.runs.values()]
.filter((run) => run.threadId === threadId)
.sort((a, b) => a.startedAt - b.startedAt)
return Promise.resolve(matching)
}
listByParentRun(parentRunId: string): Promise<Array<RunRecord>> {
const matching = [...this.runs.values()]
.filter((run) => run.parentRunId === parentRunId)
.sort((a, b) => a.startedAt - b.startedAt)
return Promise.resolve(matching)
}
listReclaimable(opts: {
now: number
ttlMs: number
}): Promise<Array<RunRecord>> {
const cutoff = opts.now - opts.ttlMs
const matching = [...this.runs.values()].filter(
(run) =>
run.status === 'running' &&
run.detachedSince !== undefined &&
run.detachedSince <= cutoff,
)
return Promise.resolve(matching)
}
}
class MemoryGenerationRunStore implements GenerationRunStore {
private readonly generationRuns = new Map<string, GenerationRunRecord>()
createOrResume(
input: Pick<
GenerationRunRecord,
'runId' | 'threadId' | 'activity' | 'provider' | 'model' | 'startedAt'
> & { status?: GenerationRunRecord['status'] },
): Promise<GenerationRunRecord> {
const existing = this.generationRuns.get(input.runId)
if (existing) return Promise.resolve(existing)
const record: GenerationRunRecord = {
runId: input.runId,
threadId: input.threadId,
activity: input.activity,
provider: input.provider,
model: input.model,
status: input.status ?? 'running',
startedAt: input.startedAt,
}
this.generationRuns.set(record.runId, record)
return Promise.resolve(record)
}
update(
runId: string,
patch: Partial<
Pick<
GenerationRunRecord,
'status' | 'finishedAt' | 'error' | 'result' | 'artifacts' | 'usage'
>
>,
): Promise<void> {
const existing = this.generationRuns.get(runId)
if (existing) this.generationRuns.set(runId, { ...existing, ...patch })
return Promise.resolve()
}
get(runId: string): Promise<GenerationRunRecord | null> {
return Promise.resolve(this.generationRuns.get(runId) ?? null)
}
findLatestForThread(threadId: string): Promise<GenerationRunRecord | null> {
const linked = [...this.generationRuns.values()]
.filter((run) => run.threadId === threadId)
.sort((a, b) => b.startedAt - a.startedAt)
return Promise.resolve(linked[0] ?? null)
}
}
function byRequestedAt(a: InterruptRecord, b: InterruptRecord): number {
return a.requestedAt - b.requestedAt
}
class MemoryInterruptStore implements InterruptStore {
private readonly interrupts = new Map<string, InterruptRecord>()
create(
record: Omit<InterruptRecord, 'status' | 'resolvedAt'>,
): Promise<void> {
// Insert-if-absent (canonical semantics, matching the SQL backends'
// ON CONFLICT DO NOTHING): a duplicate id must never clobber an existing —
// possibly already resolved — interrupt back to pending.
if (!this.interrupts.has(record.interruptId)) {
this.interrupts.set(record.interruptId, { ...record, status: 'pending' })
}
return Promise.resolve()
}
resolve(interruptId: string, response?: unknown): Promise<void> {
const existing = this.interrupts.get(interruptId)
if (existing) {
this.interrupts.set(interruptId, {
...existing,
status: 'resolved',
resolvedAt: Date.now(),
response,
})
}
return Promise.resolve()
}
cancel(interruptId: string): Promise<void> {
const existing = this.interrupts.get(interruptId)
if (existing) {
this.interrupts.set(interruptId, {
...existing,
status: 'cancelled',
resolvedAt: Date.now(),
})
}
return Promise.resolve()
}
async commitBatch(
entries: ReadonlyArray<InterruptCommitEntry>,
): Promise<void> {
const ids = new Set<string>()
for (const entry of entries) {
if (ids.has(entry.interruptId)) {
throw new Error(
`Interrupt batch contains duplicate id: ${entry.interruptId}.`,
)
}
ids.add(entry.interruptId)
const existing = this.interrupts.get(entry.interruptId)
if (!existing) {
throw new Error(
`Interrupt batch references missing id: ${entry.interruptId}.`,
)
}
if (existing.status !== 'pending') {
throw new Error(
`Interrupt batch references non-pending id: ${entry.interruptId}.`,
)
}
}
const resolvedAt = Date.now()
for (const entry of entries) {
const existing = this.interrupts.get(entry.interruptId)
if (!existing) continue
if (entry.status === 'resolved') {
this.interrupts.set(entry.interruptId, {
...existing,
status: 'resolved',
resolvedAt,
response: entry.response,
})
} else {
this.interrupts.set(entry.interruptId, {
...existing,
status: 'cancelled',
resolvedAt,
})
}
}
}
get(interruptId: string): Promise<InterruptRecord | null> {
return Promise.resolve(this.interrupts.get(interruptId) ?? null)
}
list(threadId: string): Promise<Array<InterruptRecord>> {
return Promise.resolve(
[...this.interrupts.values()]
.filter((interrupt) => interrupt.threadId === threadId)
.sort(byRequestedAt),
)
}
listPending(threadId: string): Promise<Array<InterruptRecord>> {
return Promise.resolve(
[...this.interrupts.values()]
.filter(
(interrupt) =>
interrupt.threadId === threadId && interrupt.status === 'pending',
)
.sort(byRequestedAt),
)
}
listByRun(runId: string): Promise<Array<InterruptRecord>> {
return Promise.resolve(
[...this.interrupts.values()]
.filter((interrupt) => interrupt.runId === runId)
.sort(byRequestedAt),
)
}
listPendingByRun(runId: string): Promise<Array<InterruptRecord>> {
return Promise.resolve(
[...this.interrupts.values()]
.filter(
(interrupt) =>
interrupt.runId === runId && interrupt.status === 'pending',
)
.sort(byRequestedAt),
)
}
}
class MemoryMetadataStore implements MetadataStore {
// Nested maps so composite identity is `(namespace, key)` without the
// `${namespace}:${key}` collision where `('a:b','c')` aliases `('a','b:c')`.
// (This parameter is an app-defined metadata namespace string — not the
// shared `Scope` identity type from `@tanstack/ai`.)
private readonly values = new Map<
string,
Map<string, { value: unknown; revision: string }>
>()
// One counter for the whole store, so a key that is deleted and set again
// never gets a revision that an earlier reader saw.
private lastRevision = 0
get(namespace: string, key: string): Promise<unknown | null> {
const entry = this.values.get(namespace)?.get(key)
return Promise.resolve(entry ? entry.value : null)
}
getVersioned(
namespace: string,
key: string,
): Promise<{ value: unknown; revision: string } | null> {
const entry = this.values.get(namespace)?.get(key)
return Promise.resolve(entry ? { ...entry } : null)
}
set(namespace: string, key: string, value: unknown): Promise<void> {
this.write(namespace, key, value)
return Promise.resolve()
}
setIf(
namespace: string,
key: string,
value: unknown,
expectedRevision: string | null,
): Promise<
{ ok: true; revision: string } | { ok: false; reason: 'conflict' }
> {
const current = this.values.get(namespace)?.get(key)?.revision ?? null
if (current !== expectedRevision) {
return Promise.resolve({ ok: false, reason: 'conflict' })
}
return Promise.resolve({
ok: true,
revision: this.write(namespace, key, value),
})
}
private write(namespace: string, key: string, value: unknown): string {
let bucket = this.values.get(namespace)
if (!bucket) {
bucket = new Map()
this.values.set(namespace, bucket)
}
const revision = String(++this.lastRevision)
bucket.set(key, { value, revision })
return revision
}
delete(namespace: string, key: string): Promise<void> {
const bucket = this.values.get(namespace)
if (!bucket) return Promise.resolve()
bucket.delete(key)
if (bucket.size === 0) this.values.delete(namespace)
return Promise.resolve()
}
}
class MemoryArtifactStore implements ArtifactStore {
private readonly artifacts = new Map<string, ArtifactRecord>()
save(record: ArtifactRecord): Promise<void> {
this.artifacts.set(record.artifactId, { ...record })
return Promise.resolve()
}
get(artifactId: string): Promise<ArtifactRecord | null> {
return Promise.resolve(this.artifacts.get(artifactId) ?? null)
}
list(runId: string): Promise<Array<ArtifactRecord>> {
return Promise.resolve(
[...this.artifacts.values()]
.filter((a) => a.runId === runId)
.sort(
(a, b) =>
a.createdAt - b.createdAt ||
compareUtf8Bytes(a.artifactId, b.artifactId),
),
)
}
listForThread(threadId: string): Promise<Array<ArtifactRecord>> {
return Promise.resolve(
[...this.artifacts.values()]
.filter((a) => a.threadId === threadId)
.sort(
(a, b) =>
a.createdAt - b.createdAt ||
compareUtf8Bytes(a.artifactId, b.artifactId),
),
)
}
delete(artifactId: string): Promise<void> {
this.artifacts.delete(artifactId)
return Promise.resolve()
}
deleteForRun(runId: string): Promise<void> {
for (const artifact of this.artifacts.values()) {
if (artifact.runId === runId) this.artifacts.delete(artifact.artifactId)
}
return Promise.resolve()
}
}
interface MemoryBlobEntry {
record: BlobRecord
bytes: Uint8Array
}
const textEncoder = new TextEncoder()
const textDecoder = new TextDecoder()
function copyBytes(bytes: Uint8Array): Uint8Array {
return new Uint8Array(bytes)
}
function bytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer {
const buffer = new ArrayBuffer(bytes.byteLength)
new Uint8Array(buffer).set(bytes)
return buffer
}
async function bytesFromStream(
stream: ReadableStream<Uint8Array>,
): Promise<Uint8Array> {
const reader = stream.getReader()
const chunks: Array<Uint8Array> = []
let total = 0
try {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(copyBytes(value))
total += value.byteLength
}
} finally {
reader.releaseLock()
}
const bytes = new Uint8Array(total)
let offset = 0
for (const chunk of chunks) {
bytes.set(chunk, offset)
offset += chunk.byteLength
}
return bytes
}
async function bytesFromBlobBody(body: BlobBody): Promise<Uint8Array> {
if (typeof body === 'string') {
return textEncoder.encode(body)
}
if (body instanceof ArrayBuffer) {
return new Uint8Array(body.slice(0))
}
if (ArrayBuffer.isView(body)) {
return copyBytes(
new Uint8Array(body.buffer, body.byteOffset, body.byteLength),
)
}
if (typeof Blob !== 'undefined' && body instanceof Blob) {
return new Uint8Array(await body.arrayBuffer())
}
if (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) {
return bytesFromStream(body)
}
throw new TypeError('Unsupported blob body.')
}
function blobRecordSnapshot(record: BlobRecord): BlobRecord {
return {
...record,
...(record.customMetadata
? { customMetadata: { ...record.customMetadata } }
: {}),
}
}
function blobObject(
record: BlobRecord,
bytes: Uint8Array,
range?: BlobRange,
): BlobObject {
// `size` keeps reporting the whole object; only the bytes narrow.
const served = range
? resolveBlobRange(bytes.byteLength, range)
: { offset: 0, length: bytes.byteLength }
const copied = copyBytes(
bytes.subarray(served.offset, served.offset + served.length),
)
return {
...blobRecordSnapshot(record),
...(range ? { range: served } : {}),
body: new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(copyBytes(copied))
controller.close()
},
}),
arrayBuffer: () => Promise.resolve(bytesToArrayBuffer(copied)),
text: () => Promise.resolve(textDecoder.decode(copied)),
}
}
class MemoryBlobStore implements BlobStore {
private readonly blobs = new Map<string, MemoryBlobEntry>()
private nextEtag = 1
async put(
key: string,
body: BlobBody,
options?: BlobPutOptions,
): Promise<BlobRecord> {
const bytes = await bytesFromBlobBody(body)
const existing = this.blobs.get(key)
const now = Date.now()
const record: BlobRecord = {
key,
size: bytes.byteLength,
etag: String(this.nextEtag++),
contentType:
options?.contentType ??
(typeof Blob !== 'undefined' && body instanceof Blob
? body.type || undefined
: undefined),
customMetadata: options?.customMetadata
? { ...options.customMetadata }
: undefined,
createdAt: existing?.record.createdAt ?? now,
updatedAt: now,
}
this.blobs.set(key, { record, bytes: copyBytes(bytes) })
return blobRecordSnapshot(record)
}
get(key: string, options?: BlobGetOptions): Promise<BlobObject | null> {
const entry = this.blobs.get(key)
return Promise.resolve(
entry ? blobObject(entry.record, entry.bytes, options?.range) : null,
)
}
head(key: string): Promise<BlobRecord | null> {
const entry = this.blobs.get(key)
return Promise.resolve(entry ? blobRecordSnapshot(entry.record) : null)
}
delete(key: string): Promise<void> {
this.blobs.delete(key)
return Promise.resolve()
}
list(options?: BlobListOptions): Promise<{
objects: Array<BlobRecord>
cursor?: string
truncated?: boolean
}> {
const limit = options?.limit
if (limit === 0) {
return Promise.resolve({ objects: [], truncated: false })
}
const keys = [...this.blobs.keys()]
.filter((key) => key.startsWith(options?.prefix ?? ''))
.filter((key) => options?.cursor === undefined || key > options.cursor)
.sort()
const pageKeys = limit === undefined ? keys : keys.slice(0, limit)
const objects = pageKeys.map((key) => {
const blob = this.blobs.get(key)
if (blob === undefined) {
throw new Error(`Missing blob for listed key: ${key}`)
}
return blobRecordSnapshot(blob.record)
})
const truncated = limit !== undefined && keys.length > limit
return Promise.resolve({
objects,
...(truncated ? { cursor: pageKeys.at(-1), truncated } : {}),
})
}
}
/**
* A JSON copy, so a store never shares an object with a caller. Throws when
* the value cannot become JSON (a `BigInt`, a cycle).
*/
function copyJson<T>(value: T): T {
return JSON.parse(JSON.stringify(value))
}
/**
* Throw a `RangeError` for a duration that the log contract refuses: a value
* below 0, `NaN`, or 0 when `allowZero` is false.
*/
function assertMs({
name,
value,
allowZero,
}: {
name: string
value: number
allowZero: boolean
}) {
const isValid = allowZero ? value >= 0 : value > 0
if (!isValid) {
const rule = allowZero ? '0 or more' : 'more than 0'
throw new RangeError(`${name} must be ${rule}, got ${value}.`)
}
}
/**
* The ownership record of a log that a host acquired. The record stays after
* a release, because the epoch never goes down.
*/
interface Ownership {
harness: string
ownerId: string
epoch: number
/** Epoch milliseconds, from the store clock. */
expiresAt: number
released: boolean
/** Epoch milliseconds, from `release({ wakeInMs })`. */
wakeAt?: number
}
/** An input that a host offered to a log. */
interface Offer {
record: LogRecord
/** Epoch milliseconds, from the store clock. */
offeredAt: number
/** True after an append consumed it. The id stays, so a retry is a duplicate. */
consumed: boolean
}
/** The offers of one log. */
interface Lane {
/** The harness of the first offer, for `listLogsWithoutOwner`. */
harness: string
/** By input id, in offer order. */
inputs: Map<string, Offer>
}
/** The records that a log keeps. A truncate drops the first ones. */
interface KeptLog {
/** The position of `records[0]`. A truncate moves it up. */
firstSeq: number
records: Array<LogRecord>
}
/**
* The last position of the log, or 0 for a log with no records. A truncate
* does not change it.
*/
function headOf(log: KeptLog) {
return log.firstSeq - 1 + log.records.length
}
/** The listeners of one store instance, by log id. */
type Listeners = Map<string, Set<() => void>>
/**
* The state of the memory log, shared by every store instance on it. Make one
* with {@link memoryLogState}. Two instances on one state act as two machines
* on one database: they see the same records, owners, offers, and snapshots,
* and a write on one calls the listeners of both.
*/
class MemoryLogState {
/** The store clock, in epoch milliseconds. */
readonly now: () => number
readonly logs = new Map<string, KeptLog>()
readonly snapshots = new Map<string, LogSnapshot>()
readonly owners = new Map<string, Ownership>()
// Kept apart from `owners`, so a pin stays when its owner releases the log.
readonly affinities = new Map<string, string>()
/** The deleted logs. Only an `acquireOwnership` lets a write or an offer in again. */
readonly deleted = new Set<string>()
/** When the heartbeat of each host expires, by its host id. */
readonly hosts = new Map<string, number>()
// Kept apart from `owners`, because any machine can offer to a log that no
// host acquired.
readonly lanes = new Map<string, Lane>()
/** The listeners of each store instance on this backend. */
readonly instances = new Set<Listeners>()
constructor(now: () => number) {
this.now = now
}
/**
* Call the listeners of `logId` on every store instance. The write is done,
* so a listener error must not fail it or stop the other listeners.
*/
notify(logId: string) {
const instances = [...this.instances]
const listeners = instances.flatMap((instance) => [
...(instance.get(logId) ?? []),
])
for (const listener of listeners) {
try {
listener()
} catch (error) {
console.error('[ai-persistence] log listener failed', error)
}
}
}
}
/** The type of the shared state from {@link memoryLogState}. */
export type { MemoryLogState }
// The methods are `async`, so a thrown error becomes a rejection. They never
// await, so each call runs to its end before the next one starts, also across
// the store instances on one backend.
class MemoryLogStore implements LogStore {
private readonly backend: MemoryLogState
private readonly listeners: Listeners = new Map()
constructor(backend: MemoryLogState) {
this.backend = backend
backend.instances.add(this.listeners)
}
/**
* The fence of `append`. A log that a host acquired needs the current epoch
* and an owner that did not release it. A log that no host acquired needs
* NO_OWNER_EPOCH. A deleted log refuses every write until an `acquireOwnership`.
*/
private canWrite(logId: string, epoch: number) {
if (this.backend.deleted.has(logId)) return false
const owner = this.backend.owners.get(logId)
if (owner === undefined) return epoch === NO_OWNER_EPOCH
return owner.epoch === epoch && !owner.released
}
/**
* The fence of `pinToWorkers`, `putSnapshot`, and `truncate`: only the
* owner writes them, so a log that no host acquired refuses them.
*/
private assertOwned(logId: string, epoch: number) {
const isOwned = epoch !== NO_OWNER_EPOCH && this.canWrite(logId, epoch)
if (!isOwned) throw new OwnershipLostError(logId, epoch)
}
/** The record, when `ownerId` holds the log at `epoch` and did not release it. */
private heldBy(logId: string, ownerId: string, epoch: number) {
const owner = this.backend.owners.get(logId)
const isHolder =
owner?.ownerId === ownerId && owner.epoch === epoch && !owner.released
return isHolder ? owner : undefined
}
/** The kept records of `logId`, or an empty log that is not stored yet. */
private logOf(logId: string) {
return this.backend.logs.get(logId) ?? { firstSeq: 1, records: [] }
}
/** The offers of `logId` that no append consumed, in offer order. */
private pending(logId: string) {
const inputs = this.backend.lanes.get(logId)?.inputs ?? []
return [...inputs].filter(([, offer]) => !offer.consumed)
}
async append({
logId,
seq,
epoch,
records,
consumes = [],
}: Parameters<LogStore['append']>[0]) {
const isNoop = records.length === 0 && consumes.length === 0
if (isNoop) return
// The fence comes first, so a stale writer gets OwnershipLostError, also
// at a taken position.
if (!this.canWrite(logId, epoch)) {
throw new OwnershipLostError(logId, epoch)
}
const log = this.logOf(logId)
// The position check comes before the consumes check, so the writer that
// lost a race gets LogConflictError, also when the winner consumed the
// same offers.
if (seq !== headOf(log) + 1) throw new LogConflictError(logId, seq)
const inputs = this.backend.lanes.get(logId)?.inputs
const offers = consumes.map((inputId) => {
const offer = inputs?.get(inputId)
const isPending = offer !== undefined && !offer.consumed
if (!isPending) {
throw new TypeError(
`Input ${JSON.stringify(inputId)} is not a pending offer of log ${JSON.stringify(logId)}.`,
)
}
return offer
})
// Copy the whole batch before the write, so a record that cannot become
// JSON rejects the batch with nothing written and nothing consumed.
const copies = records.map(copyJson)
log.records.push(...copies)
this.backend.logs.set(logId, log)
for (const offer of offers) offer.consumed = true
this.backend.notify(logId)
}
async read({ logId, after = 0, limit }: Parameters<LogStore['read']>[0]) {
const log = this.logOf(logId)
// The record at `after + 1` is dropped, so the page would have a hole.
if (after < log.firstSeq - 1) {
throw new LogTruncatedError(logId, log.firstSeq)
}
const start = after - (log.firstSeq - 1)
const end = limit === undefined ? log.records.length : start + limit
return log.records.slice(start, end).map((record, index) => ({
seq: after + index + 1,
record: copyJson(record),
}))
}
subscribe({ logId, listener }: Parameters<LogStore['subscribe']>[0]) {
let set = this.listeners.get(logId)
if (!set) {
set = new Set()
this.listeners.set(logId, set)
}
set.add(listener)
return () => {
set.delete(listener)
}
}
async acquireOwnership({
logId,
harness,
ownerId,
ttlMs,
affinity,
}: Parameters<LogStore['acquireOwnership']>[0]) {
assertMs({ name: 'ttlMs', value: ttlMs, allowZero: false })
const now = this.backend.now()
const owner = this.backend.owners.get(logId)
const isLive =
owner !== undefined && !owner.released && owner.expiresAt > now
if (isLive && owner.ownerId !== ownerId) return null
if (this.isPinnedElsewhere(logId, affinity, now)) return null
// The caller holds the log already: extend it at the same epoch.
if (isLive) {
owner.expiresAt = now + ttlMs
return { epoch: owner.epoch }
}
const epoch = (owner?.epoch ?? NO_OWNER_EPOCH) + 1
// Another owner that did not release: its ownership expired.
const expired =
owner !== undefined && !owner.released && owner.ownerId !== ownerId
// A new owner opens a deleted log again.
this.backend.deleted.delete(logId)
this.backend.owners.set(logId, {
harness,
ownerId,
epoch,
expiresAt: now + ttlMs,
released: false,
})
return expired ? { epoch, expired: true } : { epoch }
}
async renewOwnership({
logId,
ownerId,
epoch,
ttlMs,
}: Parameters<LogStore['renewOwnership']>[0]) {
assertMs({ name: 'ttlMs', value: ttlMs, allowZero: false })
// An expired owner can renew. A new acquire would have changed the epoch.
const owner = this.heldBy(logId, ownerId, epoch)
if (owner === undefined) return false
owner.expiresAt = this.backend.now() + ttlMs
return true
}
async releaseOwnership({
logId,
ownerId,
epoch,
wakeInMs,
}: Parameters<LogStore['releaseOwnership']>[0]) {
if (wakeInMs !== undefined) {
assertMs({ name: 'wakeInMs', value: wakeInMs, allowZero: true })
}
const owner = this.heldBy(logId, ownerId, epoch)
if (owner === undefined) return
owner.released = true
if (wakeInMs !== undefined) owner.wakeAt = this.backend.now() + wakeInMs
}
async markHostAlive({
hostId,
ttlMs,
}: Parameters<LogStore['markHostAlive']>[0]) {
assertMs({ name: 'ttlMs', value: ttlMs, allowZero: false })
this.backend.hosts.set(hostId, this.backend.now() + ttlMs)
}
/**
* True when `logId` is pinned to a host that is not `affinity`, and that
* host is alive: its heartbeat did not expire. The pin of a host with no
* live heartbeat does not count.
*/
private isPinnedElsewhere(
logId: string,
affinity: string | undefined,
now: number,
) {
const pin = this.backend.affinities.get(logId)
if (pin === undefined || pin === affinity) return false
return (this.backend.hosts.get(pin) ?? 0) > now
}
async pinToWorkers({
logId,
epoch,
affinity,
}: Parameters<LogStore['pinToWorkers']>[0]) {
this.assertOwned(logId, epoch)
if (affinity === null) this.backend.affinities.delete(logId)
else this.backend.affinities.set(logId, affinity)
}
async listLogsWithoutOwner({
limit,
harnesses,
affinity,
}: Parameters<LogStore['listLogsWithoutOwner']>[0]) {
const now = this.backend.now()
// A log with offers can have no ownership record, so look in both maps.
const logIds = new Set([
...this.backend.owners.keys(),
...this.backend.lanes.keys(),
])
const due: Array<{ orphan: OrphanedLog; dueAt: number }> = []
for (const logId of logIds) {
const found = this.orphanOf(logId, now)
if (found === undefined) continue
const isOtherHarness =
harnesses !== undefined && !harnesses.includes(found.orphan.harness)
if (isOtherHarness || this.isPinnedElsewhere(logId, affinity, now)) {
continue
}
due.push(found)
}
// The log that is due first comes first.
due.sort((left, right) => left.dueAt - right.dueAt)
return due.slice(0, limit).map(({ orphan }) => orphan)
}
/**
* Why `logId` is orphaned and when it became due, or `undefined` when it
* has no work. The first reason that applies wins: `'expired'`, then
* `'offered'`, then `'wake'`.
*/
private orphanOf(logId: string, now: number) {
const owner = this.backend.owners.get(logId)
const lane = this.backend.lanes.get(logId)
const [firstPending] = this.pending(logId)
const found = (
reason: OrphanedLog['reason'],
harness: string,
dueAt: number,
) => ({ orphan: { logId, harness, reason }, dueAt })
// An owner that did not release the log has the work until it expires.
const isHeld = owner !== undefined && !owner.released
if (isHeld) {
const isExpired = owner.expiresAt <= now
return isExpired
? found('expired', owner.harness, owner.expiresAt)
: undefined
}
if (lane !== undefined && firstPending !== undefined) {
const [, offer] = firstPending
return found('offered', owner?.harness ?? lane.harness, offer.offeredAt)
}
const wakeAt = owner?.wakeAt
const isWakeDue = owner !== undefined && wakeAt !== undefined
if (isWakeDue && wakeAt <= now) {
return found('wake', owner.harness, wakeAt)
}
return undefined
}
async offerInput({
logId,
harness,
inputId,
record,
}: Parameters<LogStore['offerInput']>[0]) {
// Copy first, so a record that cannot become JSON stores nothing.
const copy = copyJson(record)
// A late input must not start a deleted log again.
if (this.backend.deleted.has(logId)) return 'deleted'
const lane = this.backend.lanes.get(logId)
const inputs = lane?.inputs ?? new Map<string, Offer>()
// A consumed id stays taken too.
const previous = inputs.get(inputId)
if (previous !== undefined) {
const isEqual = JSON.stringify(previous.record) === JSON.stringify(copy)
return isEqual ? 'duplicate' : 'conflict'
}
inputs.set(inputId, {
record: copy,
offeredAt: this.backend.now(),
consumed: false,
})
// The owner or the first offer names the harness of the log. A later
// offer does not change it.
const owned = lane?.harness ?? this.backend.owners.get(logId)?.harness
this.backend.lanes.set(logId, { harness: owned ?? harness, inputs })
this.backend.notify(logId)
return 'offered'
}
async listOfferedInputs({
logId,
}: Parameters<LogStore['listOfferedInputs']>[0]) {
return this.pending(logId).map(([inputId, offer]) => ({
inputId,
record: copyJson(offer.record),
offeredAt: offer.offeredAt,
}))
}
async getSnapshot({ logId }: Parameters<LogStore['getSnapshot']>[0]) {
const snapshot = this.backend.snapshots.get(logId)
return snapshot === undefined ? null : copyJson(snapshot)
}
async putSnapshot({
logId,
epoch,
seq,
version,
state,
}: Parameters<LogStore['putSnapshot']>[0]) {
this.assertOwned(logId, epoch)
const log = this.logOf(logId)
const head = headOf(log)
if (seq > head) {
throw new RangeError(
`Snapshot position ${seq} is after the last position ${head} of log ${JSON.stringify(logId)}.`,
)
}
// A reader replays the records after the snapshot. The records before
// `firstSeq` are dropped, so the lowest snapshot is at `firstSeq - 1`.
const lowest = log.firstSeq - 1
if (seq < lowest) {
throw new RangeError(
`Snapshot position ${seq} is before position ${lowest} of log ${JSON.stringify(logId)}. The log dropped the records before ${log.firstSeq}.`,
)
}
// Copy last, so a state that cannot become JSON keeps the old snapshot.
this.backend.snapshots.set(logId, copyJson({ seq, version, state }))
}
async truncate({
logId,
epoch,
beforeSeq,
}: Parameters<LogStore['truncate']>[0]) {
this.assertOwned(logId, epoch)
// A reader that gets LogTruncatedError loads the snapshot, so the
// snapshot must hold every record that the cut drops.
const snapshot = this.backend.snapshots.get(logId)
const isCovered = snapshot !== undefined && snapshot.seq >= beforeSeq - 1
if (!isCovered) {
throw new RangeError(
`Log ${JSON.stringify(logId)} has no snapshot at position ${beforeSeq - 1} or later, so it keeps the records before ${beforeSeq}.`,
)
}
// A snapshot is never after the head, so the cut drops kept records
// only. A cut before `firstSeq` drops nothing.
const log = this.logOf(logId)
const dropCount = beforeSeq - log.firstSeq
if (dropCount <= 0) return
log.records.splice(0, dropCount)
log.firstSeq = beforeSeq
}
async deleteLog({ logId }: Parameters<LogStore['deleteLog']>[0]) {
this.backend.logs.delete(logId)
this.backend.snapshots.delete(logId)
this.backend.lanes.delete(logId)
this.backend.affinities.delete(logId)
// A late write must not bring the log back, so every write fails until
// an `acquireOwnership`. Keep the epoch counter. The log has no owner now, as
// after a release with no wake.
this.backend.deleted.add(logId)
const owner = this.backend.owners.get(logId)
if (owner !== undefined) {
owner.released = true
owner.wakeAt = undefined
}
this.backend.notify(logId)
}
}
/**
* In-process reference {@link LogStore}, for tests and one-process hosts. The
* log is lost when the process stops. Records are JSON-copied in and out.
*
* `memoryPersistence()` has one. Make your own to share a
* {@link memoryLogState} between two stores.
*
* @param options.state - The log state to use. Pass one
* {@link memoryLogState} to two stores, and they act as two machines on
* one database. Without it, the store makes its own state.
*
* @example
* ```ts
* const state = memoryLogState()
* const machineA = defineAIPersistence({
* stores: { ...memoryPersistence().stores, log: memoryLogStore({ state }) },
* })
* ```
*/
export function memoryLogStore({
state = memoryLogState(),
}: { state?: MemoryLogState } = {}): LogStore {
return new MemoryLogStore(state)
}
/**
* A new log state for {@link memoryLogStore}. Give one state to two stores
* to test two hosts in one process: an append on one store calls the
* listeners on the other, and both see one owner per log.
*
* @example
* ```ts
* const state = memoryLogState()
* const hostA = memoryLogStore({ state })
* const hostB = memoryLogStore({ state })
* hostB.subscribe({ logId: 'session-1', listener: () => console.log('new') })
* await hostA.append({
* logId: 'session-1',
* seq: 1,
* epoch: NO_OWNER_EPOCH,
* records: [{ type: 'hello' }],
* })
* // The listener on hostB ran.
* ```
*
* @param options.now - The store clock, in epoch milliseconds. Ownership
* expiry, wake times, and offer times use it. Default: `Date.now()`. Give a
* test clock to move time in a test.
*
* @example
* ```ts
* let clock = 0
* const state = memoryLogState({ now: () => clock })
* // A wake time of 5 s from now is due after this:
* clock += 5_000
* ```
*/
export function memoryLogState({ now }: { now?: () => number } = {}) {
return new MemoryLogState(now ?? (() => Date.now()))
}
// Newest `updatedAt` first, then `threadId` order. The cursor uses it too.
const bySessionOrder = (
a: Pick<SessionIndexEntry, 'updatedAt' | 'threadId'>,
b: Pick<SessionIndexEntry, 'updatedAt' | 'threadId'>,
) => b.updatedAt - a.updatedAt || compareUtf8Bytes(a.threadId, b.threadId)
// The cursor is `<updatedAt>:<threadId>` of the last entry of a page.
function parseSessionCursor(cursor: string) {
const split = cursor.indexOf(':')
const updatedAt = Number(cursor.slice(0, split))
if (split <= 0 || !Number.isFinite(updatedAt)) {
throw new Error(`Invalid session index cursor: ${cursor}`)
}
return { updatedAt, threadId: cursor.slice(split + 1) }
}
class MemorySessionIndexStore implements SessionIndexStore {
// Nested maps, tenant then thread. A joined `${tenant}:${thread}` key would
// make `('a:b', 'c')` and `('a', 'b:c')` the same entry. A missing tenant is
// the default tenant, `''`.
private readonly tenants = new Map<
string,
Map<string, { entry: SessionIndexEntry; revision: string }>
>()
// One counter for the whole store, so an entry that is deleted and written
// again never gets a revision that an earlier reader saw.
private lastRevision = 0
get(key: SessionIndexKey) {
const stored = this.find(key)
return Promise.resolve(stored && structuredClone(stored.entry))
}
getVersioned(key: SessionIndexKey) {
const stored = this.find(key)
return Promise.resolve(stored && structuredClone(stored))
}
upsert(entry: SessionIndexEntry) {
this.write(entry)
return Promise.resolve()
}
upsertIf({
entry,
revision,
}: {
entry: SessionIndexEntry
revision: string | null
}) {
const current = this.find(entry)?.revision ?? null
if (current !== revision) {
return Promise.resolve({ ok: false, reason: 'conflict' } as const)
}
return Promise.resolve({ ok: true, revision: this.write(entry) } as const)
}
list(options: SessionIndexListOptions = {}) {
const { limit, cursor, parentThreadId, memberId, search, harness } = options
const after = cursor === undefined ? undefined : parseSessionCursor(cursor)
const needle = search?.toLowerCase()
const metadata = Object.entries(options.metadata ?? {})
const tenant = this.tenants.get(options.tenantId ?? '')
const stored = [...(tenant?.values() ?? [])]
const matching = stored
.map(({ entry }) => entry)
.filter((entry) => {
// `null` asks for the entries with no parent.
const isChild =
parentThreadId === undefined ||
(entry.parentThreadId ?? null) === parentThreadId
const isMember =
memberId === undefined ||
entry.members.some((member) => member.userId === memberId)
const isAfterCursor =
after === undefined || bySessionOrder(after, entry) < 0
const isFound =
needle === undefined ||
(entry.title?.toLowerCase().includes(needle) ?? false)
const isHarness = harness === undefined || entry.harness === harness
const hasMetadata = metadata.every(
([key, value]) => entry.metadata?.[key] === value,
)
const isShown = options.withArchived === true || entry.archived !== true
return (
isShown &&
isChild &&
isMember &&
isFound &&
isHarness &&
hasMetadata &&
isAfterCursor
)
})
.sort(bySessionOrder)
const page = limit === undefined ? matching : matching.slice(0, limit)
const last = page.at(-1)
const hasMore =
limit !== undefined && matching.length > limit && last !== undefined
return Promise.resolve({
entries: page.map((entry) => structuredClone(entry)),
...(hasMore
? { cursor: `${last.updatedAt}:${last.threadId}`, truncated: true }
: {}),
})
}
delete(key: SessionIndexKey) {
const tenantId = key.tenantId ?? ''
const tenant = this.tenants.get(tenantId)
if (!tenant) return Promise.resolve()
tenant.delete(key.threadId)
if (tenant.size === 0) this.tenants.delete(tenantId)
return Promise.resolve()
}
private find(key: SessionIndexKey) {
return this.tenants.get(key.tenantId ?? '')?.get(key.threadId)
}
// Copies in and out, so a caller never changes a stored entry.
private write(entry: SessionIndexEntry) {
const tenantId = entry.tenantId ?? ''
let tenant = this.tenants.get(tenantId)
if (!tenant) {
tenant = new Map()
this.tenants.set(tenantId, tenant)
}
const revision = String(++this.lastRevision)
tenant.set(entry.threadId, { entry: structuredClone(entry), revision })
return revision
}
}
interface MemoryPersistenceStores {
messages: MessageStore
activities: ActivityStore
runs: RunStore
generationRuns: GenerationRunStore
interrupts: InterruptStore
metadata: MetadataStore
artifacts: ArtifactStore
blobs: BlobStore
sessions: SessionIndexStore
log: LogStore
}
/**
* In-process reference backend for the full state + generation store set.
*
* Returns messages + activities + runs + generationRuns + interrupts +
* metadata + artifacts + blobs + sessions + log.
* Plain `chat()` does not use `log`. A harness host keeps each session in
* `log` and does not use `messages`. Locks are not included. Use
* `InMemoryLockStore` + `withLocks` from `@tanstack/ai` when a test or
* single-process app needs coordination.
*
* @example
* ```ts
* const persistence = memoryPersistence()
* ```
*/
export function memoryPersistence() {
const stores: MemoryPersistenceStores = {
messages: new MemoryMessageStore(),
activities: new MemoryActivityStore(),
runs: new MemoryRunStore(),
generationRuns: new MemoryGenerationRunStore(),
interrupts: new MemoryInterruptStore(),
metadata: new MemoryMetadataStore(),
artifacts: new MemoryArtifactStore(),
blobs: new MemoryBlobStore(),
sessions: new MemorySessionIndexStore(),
log: memoryLogStore(),
}
return defineAIPersistence({ stores })
}