@tanstack/ai-persistence
Version:
Composable state persistence for TanStack AI messages, runs, interrupts, metadata, and locks.
874 lines (873 loc) • 30.5 kB
JavaScript
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