@tanstack/ai-persistence
Version:
Composable state persistence for TanStack AI messages, runs, interrupts, metadata, and locks.
75 lines (74 loc) • 3.21 kB
JavaScript
import { validateReconstructChatStores } from "./types.js";
import { modelMessagesToUIMessages } from "@tanstack/ai";
//#region src/reconstruct.ts
/**
* Build the JSON `Response` a server-authoritative client hydrates from on load
* (see the client-persistence guide). Reads the thread id from the request query
* (`?threadId=` by default) and returns `{ messages, activeRun, interrupts }`
* ({@link ReconstructedChat}):
*
* - `messages` — the stored transcript as UI messages.
* - `activeRun` — `{ runId }` if a run is still generating for the thread (so the
* client tails it via the durability stream), else `null`. Resolved via the
* required `stores.runs.findActiveRun`; `null` when the `runs` store is absent.
* - `interrupts` — `{ runId, pending }` if the thread has pending human-in-the-loop
* interrupts (a paused approval / wait) and the run they paused, else `null`, so
* a reload re-prompts the decision from the server. Resolved via the optional
* `stores.interrupts.listPending`; `null` when that store is absent.
*
* Requires `stores.messages`. Returns an empty transcript with no active run
* and no interrupts when the thread id is missing or the thread is unknown, so
* the caller never has to special-case a first load.
*
* This helper does **not** enforce tenancy by itself. Pass
* {@link ReconstructChatOptions.authorize} (or wrap the call in your own
* session gate) before exposing it on a public route.
*
* ```ts
* export async function GET(request: Request) {
* return reconstructChat(persistence, request, {
* authorize: async (threadId, req) => {
* const userId = await getSessionUserId(req)
* return userId != null && (await userOwnsThread(userId, threadId))
* },
* })
* }
* ```
*/
async function reconstructChat(persistence, request, options) {
validateReconstructChatStores(persistence);
const messageStore = persistence.stores.messages;
if (!messageStore) throw new Error("reconstructChat requires stores.messages.");
const param = options?.param ?? "threadId";
const threadId = new URL(request.url).searchParams.get(param) ?? "";
if (threadId && options?.authorize) {
const decision = await options.authorize(threadId, request);
if (decision instanceof Response) return decision;
if (!decision) return new Response(JSON.stringify({ error: "Forbidden" }), {
status: 403,
headers: {
"content-type": "application/json",
"cache-control": "no-store"
}
});
}
const active = threadId ? await persistence.stores.runs?.findActiveRun(threadId) : null;
const stored = threadId ? await messageStore.loadThread(threadId) : [];
const pending = threadId ? await persistence.stores.interrupts?.listPending(threadId) ?? [] : [];
const firstPending = pending[0];
const body = {
messages: modelMessagesToUIMessages(stored),
activeRun: active ? { runId: active.runId } : null,
interrupts: firstPending ? {
runId: firstPending.runId,
pending: pending.map((record) => record.payload)
} : null
};
return new Response(JSON.stringify(body), { headers: {
"content-type": "application/json",
"cache-control": "no-store"
} });
}
//#endregion
export { reconstructChat };
//# sourceMappingURL=reconstruct.js.map