UNPKG

@tanstack/ai-persistence

Version:

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

874 lines (873 loc) • 30.5 kB
import { LogConflictError, LogTruncatedError, OwnershipLostError, defineAIPersistence } from "./types.js"; import { resolveBlobRange } from "./blob-range.js"; //#region src/memory.ts var compareUtf8Bytes = (left, right) => { 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; }; var MemoryMessageStore = class { threads = /* @__PURE__ */ new Map(); loadThread(threadId, _options) { return Promise.resolve(this.threads.get(threadId)?.slice() ?? []); } saveThread(threadId, messages) { this.threads.set(threadId, messages.slice()); return Promise.resolve(); } }; var MemoryActivityStore = class { threads = /* @__PURE__ */ new Map(); loadActivities(threadId) { return Promise.resolve(this.threads.get(threadId)?.slice() ?? []); } saveActivities(threadId, activities) { this.threads.set(threadId, activities.slice()); return Promise.resolve(); } }; var MemoryRunStore = class { runs = /* @__PURE__ */ new Map(); createOrResume(input) { const existing = this.runs.get(input.runId); if (existing) return Promise.resolve(existing); const record = { runId: input.runId, threadId: input.threadId, status: input.status ?? "running", startedAt: input.startedAt, ...input.parentRunId !== void 0 ? { parentRunId: input.parentRunId } : {}, ...input.subagentRunId !== void 0 ? { subagentRunId: input.subagentRunId } : {}, ...input.name !== void 0 ? { name: input.name } : {}, ...input.kind !== void 0 ? { kind: input.kind } : {}, ...input.activity !== void 0 ? { activity: input.activity } : {}, ...input.agent !== void 0 ? { agent: input.agent } : {}, ...input.principal !== void 0 ? { principal: input.principal } : {} }; this.runs.set(record.runId, record); return Promise.resolve(record); } update(runId, patch) { const existing = this.runs.get(runId); if (existing) this.runs.set(runId, { ...existing, ...patch }); return Promise.resolve(); } get(runId) { return Promise.resolve(this.runs.get(runId) ?? null); } findActiveRun(threadId) { 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) { const matching = [...this.runs.values()].filter((run) => run.threadId === threadId).sort((a, b) => a.startedAt - b.startedAt); return Promise.resolve(matching); } listByParentRun(parentRunId) { const matching = [...this.runs.values()].filter((run) => run.parentRunId === parentRunId).sort((a, b) => a.startedAt - b.startedAt); return Promise.resolve(matching); } listReclaimable(opts) { const cutoff = opts.now - opts.ttlMs; const matching = [...this.runs.values()].filter((run) => run.status === "running" && run.detachedSince !== void 0 && run.detachedSince <= cutoff); return Promise.resolve(matching); } }; var MemoryGenerationRunStore = class { generationRuns = /* @__PURE__ */ new Map(); createOrResume(input) { const existing = this.generationRuns.get(input.runId); if (existing) return Promise.resolve(existing); const record = { 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, patch) { const existing = this.generationRuns.get(runId); if (existing) this.generationRuns.set(runId, { ...existing, ...patch }); return Promise.resolve(); } get(runId) { return Promise.resolve(this.generationRuns.get(runId) ?? null); } findLatestForThread(threadId) { 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, b) { return a.requestedAt - b.requestedAt; } var MemoryInterruptStore = class { interrupts = /* @__PURE__ */ new Map(); create(record) { if (!this.interrupts.has(record.interruptId)) this.interrupts.set(record.interruptId, { ...record, status: "pending" }); return Promise.resolve(); } resolve(interruptId, response) { const existing = this.interrupts.get(interruptId); if (existing) this.interrupts.set(interruptId, { ...existing, status: "resolved", resolvedAt: Date.now(), response }); return Promise.resolve(); } cancel(interruptId) { const existing = this.interrupts.get(interruptId); if (existing) this.interrupts.set(interruptId, { ...existing, status: "cancelled", resolvedAt: Date.now() }); return Promise.resolve(); } async commitBatch(entries) { const ids = /* @__PURE__ */ new Set(); 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) { return Promise.resolve(this.interrupts.get(interruptId) ?? null); } list(threadId) { return Promise.resolve([...this.interrupts.values()].filter((interrupt) => interrupt.threadId === threadId).sort(byRequestedAt)); } listPending(threadId) { return Promise.resolve([...this.interrupts.values()].filter((interrupt) => interrupt.threadId === threadId && interrupt.status === "pending").sort(byRequestedAt)); } listByRun(runId) { return Promise.resolve([...this.interrupts.values()].filter((interrupt) => interrupt.runId === runId).sort(byRequestedAt)); } listPendingByRun(runId) { return Promise.resolve([...this.interrupts.values()].filter((interrupt) => interrupt.runId === runId && interrupt.status === "pending").sort(byRequestedAt)); } }; var MemoryMetadataStore = class { values = /* @__PURE__ */ new Map(); lastRevision = 0; get(namespace, key) { const entry = this.values.get(namespace)?.get(key); return Promise.resolve(entry ? entry.value : null); } getVersioned(namespace, key) { const entry = this.values.get(namespace)?.get(key); return Promise.resolve(entry ? { ...entry } : null); } set(namespace, key, value) { this.write(namespace, key, value); return Promise.resolve(); } setIf(namespace, key, value, expectedRevision) { if ((this.values.get(namespace)?.get(key)?.revision ?? null) !== expectedRevision) return Promise.resolve({ ok: false, reason: "conflict" }); return Promise.resolve({ ok: true, revision: this.write(namespace, key, value) }); } write(namespace, key, value) { let bucket = this.values.get(namespace); if (!bucket) { bucket = /* @__PURE__ */ new Map(); this.values.set(namespace, bucket); } const revision = String(++this.lastRevision); bucket.set(key, { value, revision }); return revision; } delete(namespace, key) { 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(); } }; var MemoryArtifactStore = class { artifacts = /* @__PURE__ */ new Map(); save(record) { this.artifacts.set(record.artifactId, { ...record }); return Promise.resolve(); } get(artifactId) { return Promise.resolve(this.artifacts.get(artifactId) ?? null); } list(runId) { 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) { 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) { this.artifacts.delete(artifactId); return Promise.resolve(); } deleteForRun(runId) { for (const artifact of this.artifacts.values()) if (artifact.runId === runId) this.artifacts.delete(artifact.artifactId); return Promise.resolve(); } }; var textEncoder = new TextEncoder(); var textDecoder = new TextDecoder(); function copyBytes(bytes) { return new Uint8Array(bytes); } function bytesToArrayBuffer(bytes) { const buffer = new ArrayBuffer(bytes.byteLength); new Uint8Array(buffer).set(bytes); return buffer; } async function bytesFromStream(stream) { const reader = stream.getReader(); const chunks = []; let total = 0; try { 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) { 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) { return { ...record, ...record.customMetadata ? { customMetadata: { ...record.customMetadata } } : {} }; } function blobObject(record, bytes, range) { 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({ start(controller) { controller.enqueue(copyBytes(copied)); controller.close(); } }), arrayBuffer: () => Promise.resolve(bytesToArrayBuffer(copied)), text: () => Promise.resolve(textDecoder.decode(copied)) }; } var MemoryBlobStore = class { blobs = /* @__PURE__ */ new Map(); nextEtag = 1; async put(key, body, options) { const bytes = await bytesFromBlobBody(body); const existing = this.blobs.get(key); const now = Date.now(); const record = { key, size: bytes.byteLength, etag: String(this.nextEtag++), contentType: options?.contentType ?? (typeof Blob !== "undefined" && body instanceof Blob ? body.type || void 0 : void 0), customMetadata: options?.customMetadata ? { ...options.customMetadata } : void 0, createdAt: existing?.record.createdAt ?? now, updatedAt: now }; this.blobs.set(key, { record, bytes: copyBytes(bytes) }); return blobRecordSnapshot(record); } get(key, options) { const entry = this.blobs.get(key); return Promise.resolve(entry ? blobObject(entry.record, entry.bytes, options?.range) : null); } head(key) { const entry = this.blobs.get(key); return Promise.resolve(entry ? blobRecordSnapshot(entry.record) : null); } delete(key) { this.blobs.delete(key); return Promise.resolve(); } list(options) { 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 === void 0 || key > options.cursor).sort(); const pageKeys = limit === void 0 ? keys : keys.slice(0, limit); const objects = pageKeys.map((key) => { const blob = this.blobs.get(key); if (blob === void 0) throw new Error(`Missing blob for listed key: ${key}`); return blobRecordSnapshot(blob.record); }); const truncated = limit !== void 0 && 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(value) { 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 }) { if (!(allowZero ? value >= 0 : value > 0)) throw new RangeError(`${name} must be ${allowZero ? "0 or more" : "more than 0"}, got ${value}.`); } /** * The last position of the log, or 0 for a log with no records. A truncate * does not change it. */ function headOf(log) { return log.firstSeq - 1 + log.records.length; } /** * 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. */ var MemoryLogState = class { /** The store clock, in epoch milliseconds. */ now; logs = /* @__PURE__ */ new Map(); snapshots = /* @__PURE__ */ new Map(); owners = /* @__PURE__ */ new Map(); affinities = /* @__PURE__ */ new Map(); /** The deleted logs. Only an `acquireOwnership` lets a write or an offer in again. */ deleted = /* @__PURE__ */ new Set(); /** When the heartbeat of each host expires, by its host id. */ hosts = /* @__PURE__ */ new Map(); lanes = /* @__PURE__ */ new Map(); /** The listeners of each store instance on this backend. */ instances = /* @__PURE__ */ new Set(); constructor(now) { 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) { const listeners = [...this.instances].flatMap((instance) => [...instance.get(logId) ?? []]); for (const listener of listeners) try { listener(); } catch (error) { console.error("[ai-persistence] log listener failed", error); } } }; var MemoryLogStore = class { backend; listeners = /* @__PURE__ */ new Map(); constructor(backend) { 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`. */ canWrite(logId, epoch) { if (this.backend.deleted.has(logId)) return false; const owner = this.backend.owners.get(logId); if (owner === void 0) return epoch === 0; 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. */ assertOwned(logId, epoch) { if (!(epoch !== 0 && this.canWrite(logId, epoch))) throw new OwnershipLostError(logId, epoch); } /** The record, when `ownerId` holds the log at `epoch` and did not release it. */ heldBy(logId, ownerId, epoch) { const owner = this.backend.owners.get(logId); return owner?.ownerId === ownerId && owner.epoch === epoch && !owner.released ? owner : void 0; } /** The kept records of `logId`, or an empty log that is not stored yet. */ logOf(logId) { return this.backend.logs.get(logId) ?? { firstSeq: 1, records: [] }; } /** The offers of `logId` that no append consumed, in offer order. */ pending(logId) { return [...this.backend.lanes.get(logId)?.inputs ?? []].filter(([, offer]) => !offer.consumed); } async append({ logId, seq, epoch, records, consumes = [] }) { if (records.length === 0 && consumes.length === 0) return; if (!this.canWrite(logId, epoch)) throw new OwnershipLostError(logId, epoch); const log = this.logOf(logId); 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); if (!(offer !== void 0 && !offer.consumed)) throw new TypeError(`Input ${JSON.stringify(inputId)} is not a pending offer of log ${JSON.stringify(logId)}.`); return offer; }); 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 }) { const log = this.logOf(logId); if (after < log.firstSeq - 1) throw new LogTruncatedError(logId, log.firstSeq); const start = after - (log.firstSeq - 1); const end = limit === void 0 ? log.records.length : start + limit; return log.records.slice(start, end).map((record, index) => ({ seq: after + index + 1, record: copyJson(record) })); } subscribe({ logId, listener }) { let set = this.listeners.get(logId); if (!set) { set = /* @__PURE__ */ new Set(); this.listeners.set(logId, set); } set.add(listener); return () => { set.delete(listener); }; } async acquireOwnership({ logId, harness, ownerId, ttlMs, affinity }) { assertMs({ name: "ttlMs", value: ttlMs, allowZero: false }); const now = this.backend.now(); const owner = this.backend.owners.get(logId); const isLive = owner !== void 0 && !owner.released && owner.expiresAt > now; if (isLive && owner.ownerId !== ownerId) return null; if (this.isPinnedElsewhere(logId, affinity, now)) return null; if (isLive) { owner.expiresAt = now + ttlMs; return { epoch: owner.epoch }; } const epoch = (owner?.epoch ?? 0) + 1; const expired = owner !== void 0 && !owner.released && owner.ownerId !== ownerId; 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 }) { assertMs({ name: "ttlMs", value: ttlMs, allowZero: false }); const owner = this.heldBy(logId, ownerId, epoch); if (owner === void 0) return false; owner.expiresAt = this.backend.now() + ttlMs; return true; } async releaseOwnership({ logId, ownerId, epoch, wakeInMs }) { if (wakeInMs !== void 0) assertMs({ name: "wakeInMs", value: wakeInMs, allowZero: true }); const owner = this.heldBy(logId, ownerId, epoch); if (owner === void 0) return; owner.released = true; if (wakeInMs !== void 0) owner.wakeAt = this.backend.now() + wakeInMs; } async markHostAlive({ hostId, ttlMs }) { 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. */ isPinnedElsewhere(logId, affinity, now) { const pin = this.backend.affinities.get(logId); if (pin === void 0 || pin === affinity) return false; return (this.backend.hosts.get(pin) ?? 0) > now; } async pinToWorkers({ logId, epoch, affinity }) { this.assertOwned(logId, epoch); if (affinity === null) this.backend.affinities.delete(logId); else this.backend.affinities.set(logId, affinity); } async listLogsWithoutOwner({ limit, harnesses, affinity }) { const now = this.backend.now(); const logIds = /* @__PURE__ */ new Set([...this.backend.owners.keys(), ...this.backend.lanes.keys()]); const due = []; for (const logId of logIds) { const found = this.orphanOf(logId, now); if (found === void 0) continue; if (harnesses !== void 0 && !harnesses.includes(found.orphan.harness) || this.isPinnedElsewhere(logId, affinity, now)) continue; due.push(found); } 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'`. */ orphanOf(logId, now) { const owner = this.backend.owners.get(logId); const lane = this.backend.lanes.get(logId); const [firstPending] = this.pending(logId); const found = (reason, harness, dueAt) => ({ orphan: { logId, harness, reason }, dueAt }); if (owner !== void 0 && !owner.released) return owner.expiresAt <= now ? found("expired", owner.harness, owner.expiresAt) : void 0; if (lane !== void 0 && firstPending !== void 0) { const [, offer] = firstPending; return found("offered", owner?.harness ?? lane.harness, offer.offeredAt); } const wakeAt = owner?.wakeAt; if (owner !== void 0 && wakeAt !== void 0 && wakeAt <= now) return found("wake", owner.harness, wakeAt); } async offerInput({ logId, harness, inputId, record }) { const copy = copyJson(record); if (this.backend.deleted.has(logId)) return "deleted"; const lane = this.backend.lanes.get(logId); const inputs = lane?.inputs ?? /* @__PURE__ */ new Map(); const previous = inputs.get(inputId); if (previous !== void 0) return JSON.stringify(previous.record) === JSON.stringify(copy) ? "duplicate" : "conflict"; inputs.set(inputId, { record: copy, offeredAt: this.backend.now(), consumed: false }); 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 }) { return this.pending(logId).map(([inputId, offer]) => ({ inputId, record: copyJson(offer.record), offeredAt: offer.offeredAt })); } async getSnapshot({ logId }) { const snapshot = this.backend.snapshots.get(logId); return snapshot === void 0 ? null : copyJson(snapshot); } async putSnapshot({ logId, epoch, seq, version, state }) { 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)}.`); 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}.`); this.backend.snapshots.set(logId, copyJson({ seq, version, state })); } async truncate({ logId, epoch, beforeSeq }) { this.assertOwned(logId, epoch); const snapshot = this.backend.snapshots.get(logId); if (!(snapshot !== void 0 && snapshot.seq >= beforeSeq - 1)) throw new RangeError(`Log ${JSON.stringify(logId)} has no snapshot at position ${beforeSeq - 1} or later, so it keeps the records before ${beforeSeq}.`); 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 }) { this.backend.logs.delete(logId); this.backend.snapshots.delete(logId); this.backend.lanes.delete(logId); this.backend.affinities.delete(logId); this.backend.deleted.add(logId); const owner = this.backend.owners.get(logId); if (owner !== void 0) { owner.released = true; owner.wakeAt = void 0; } 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 }) }, * }) * ``` */ function memoryLogStore({ state = memoryLogState() } = {}) { 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 * ``` */ function memoryLogState({ now } = {}) { return new MemoryLogState(now ?? (() => Date.now())); } var bySessionOrder = (a, b) => b.updatedAt - a.updatedAt || compareUtf8Bytes(a.threadId, b.threadId); function parseSessionCursor(cursor) { 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) }; } var MemorySessionIndexStore = class { tenants = /* @__PURE__ */ new Map(); lastRevision = 0; get(key) { const stored = this.find(key); return Promise.resolve(stored && structuredClone(stored.entry)); } getVersioned(key) { const stored = this.find(key); return Promise.resolve(stored && structuredClone(stored)); } upsert(entry) { this.write(entry); return Promise.resolve(); } upsertIf({ entry, revision }) { if ((this.find(entry)?.revision ?? null) !== revision) return Promise.resolve({ ok: false, reason: "conflict" }); return Promise.resolve({ ok: true, revision: this.write(entry) }); } list(options = {}) { const { limit, cursor, parentThreadId, memberId, search, harness } = options; const after = cursor === void 0 ? void 0 : parseSessionCursor(cursor); const needle = search?.toLowerCase(); const metadata = Object.entries(options.metadata ?? {}); const matching = [...this.tenants.get(options.tenantId ?? "")?.values() ?? []].map(({ entry }) => entry).filter((entry) => { const isChild = parentThreadId === void 0 || (entry.parentThreadId ?? null) === parentThreadId; const isMember = memberId === void 0 || entry.members.some((member) => member.userId === memberId); const isAfterCursor = after === void 0 || bySessionOrder(after, entry) < 0; const isFound = needle === void 0 || (entry.title?.toLowerCase().includes(needle) ?? false); const isHarness = harness === void 0 || entry.harness === harness; const hasMetadata = metadata.every(([key, value]) => entry.metadata?.[key] === value); return (options.withArchived === true || entry.archived !== true) && isChild && isMember && isFound && isHarness && hasMetadata && isAfterCursor; }).sort(bySessionOrder); const page = limit === void 0 ? matching : matching.slice(0, limit); const last = page.at(-1); const hasMore = limit !== void 0 && matching.length > limit && last !== void 0; return Promise.resolve({ entries: page.map((entry) => structuredClone(entry)), ...hasMore ? { cursor: `${last.updatedAt}:${last.threadId}`, truncated: true } : {} }); } delete(key) { 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(); } find(key) { return this.tenants.get(key.tenantId ?? "")?.get(key.threadId); } write(entry) { const tenantId = entry.tenantId ?? ""; let tenant = this.tenants.get(tenantId); if (!tenant) { tenant = /* @__PURE__ */ new Map(); this.tenants.set(tenantId, tenant); } const revision = String(++this.lastRevision); tenant.set(entry.threadId, { entry: structuredClone(entry), revision }); return revision; } }; /** * 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() * ``` */ function memoryPersistence() { const stores = { 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 }); } //#endregion export { memoryLogState, memoryLogStore, memoryPersistence }; //# sourceMappingURL=memory.js.map