@tanstack/ai-persistence
Version:
Composable state persistence for TanStack AI messages, runs, interrupts, metadata, and locks.
160 lines (159 loc) • 6.04 kB
JavaScript
import { validateReconstructChatStores } from "./types.js";
import { modelMessagesToUIMessages } from "@tanstack/ai";
//#region src/reconstruct.ts
var MAX_PAGE_SIZE = 500;
/**
* 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.
*
* Paging is opt-in. A valid `?limit=` (positive integer, capped at 500) returns
* the newest window of UI messages plus `page`. `?before=` walks to an older
* window. Invalid `limit` (`0`, negative, NaN) is ignored and the full
* transcript is returned. `activeRun` and `interrupts` are never paged.
*
* 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 requestUrl = new URL(request.url);
const param = options?.param ?? "threadId";
const threadId = requestUrl.searchParams.get(param) ?? "";
const pageSize = parsePageSize(requestUrl.searchParams.get("limit"));
const before = parseBefore(requestUrl.searchParams.get("before"));
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 === "" ? [] : pageSize === void 0 ? await messageStore.loadThread(threadId) : await messageStore.loadThread(threadId, {
limit: pageSize + 1,
...before === void 0 ? {} : { before }
});
const pending = threadId ? await persistence.stores.interrupts?.listPending(threadId) ?? [] : [];
const firstPending = pending[0];
const transcript = !(pageSize !== void 0 && threadId !== "") ? { messages: modelMessagesToUIMessages(threadMessages(stored)) } : Array.isArray(stored) ? await windowFromArray({
stored,
messageStore,
threadId,
pageSize,
before
}) : windowFromMessagePage(stored, pageSize);
const body = {
messages: transcript.messages,
activeRun: active ? { runId: active.runId } : null,
interrupts: firstPending ? {
runId: firstPending.runId,
pending: pending.map((record) => record.payload)
} : null,
..."page" in transcript ? { page: transcript.page } : {}
};
return new Response(JSON.stringify(body), { headers: {
"content-type": "application/json",
"cache-control": "no-store"
} });
}
function parsePageSize(raw) {
if (raw == null) return;
const pageSize = Number(raw);
if (!(Number.isInteger(pageSize) && pageSize > 0)) return;
return Math.min(pageSize, MAX_PAGE_SIZE);
}
function parseBefore(raw) {
if (raw == null || raw === "") return;
return raw;
}
function threadMessages(loaded) {
return Array.isArray(loaded) ? loaded : loaded.messages;
}
function completePage() {
return { truncated: false };
}
function truncatedPage(cursor) {
return {
truncated: true,
cursor
};
}
function pageFromCursor(cursor) {
if (cursor === void 0 || cursor === "") return completePage();
return truncatedPage(cursor);
}
function newestUiWindow(messages, pageSize) {
if (!(messages.length > pageSize)) return {
messages,
page: completePage()
};
const uiWindow = messages.slice(messages.length - pageSize);
return {
messages: uiWindow,
page: pageFromCursor(uiWindow[0]?.id)
};
}
function uiBeforeCursor(messages, cursor) {
const cut = messages.findIndex((message) => message.id === cursor);
if (cut === -1) return;
return messages.slice(0, cut);
}
function windowFromMessagePage(page, pageSize) {
const ui = modelMessagesToUIMessages(page.messages);
if (ui.length > pageSize) return newestUiWindow(ui, pageSize);
if (page.truncated) return {
messages: ui,
page: pageFromCursor(page.cursor)
};
return {
messages: ui,
page: completePage()
};
}
async function windowFromArray(input) {
const { stored, messageStore, threadId, pageSize, before } = input;
if (before === void 0) return newestUiWindow(modelMessagesToUIMessages(stored), pageSize);
const full = threadMessages(await messageStore.loadThread(threadId));
const older = uiBeforeCursor(modelMessagesToUIMessages(full), before);
if (older === void 0) return {
messages: [],
page: truncatedPage(before)
};
return newestUiWindow(older, pageSize);
}
//#endregion
export { reconstructChat };
//# sourceMappingURL=reconstruct.js.map