@tanstack/ai-persistence
Version:
Composable state persistence for TanStack AI messages, runs, interrupts, metadata, and locks.
148 lines (147 loc) • 5.82 kB
JavaScript
import { validateReconstructGenerationStores } from "./types.js";
//#region src/reconstruct-generation.ts
/**
* Map the persisted run status to the client-facing resume-snapshot status.
* An `interrupted` or `aborted` run surfaces as `error` — the client has no live
* run to resume, and neither produced a usable result. (A generation abort is
* always terminal: there is no journal to reattach to, so `withGenerationPersistence`
* writes `'aborted'` rather than parking the run.)
*/
function snapshotStatus(status) {
switch (status) {
case "running": return "running";
case "completed": return "complete";
case "failed":
case "interrupted":
case "aborted": return "error";
}
}
function runToSnapshot(run) {
const status = snapshotStatus(run.status);
return {
schemaVersion: 1,
resumeState: status === "running" ? {
runId: run.runId,
threadId: run.threadId
} : null,
status,
...run.result !== void 0 ? { result: run.result } : {},
...run.error !== void 0 ? { error: run.error } : {},
...run.activity !== void 0 ? { activity: run.activity } : {}
};
}
function jsonResponse(body) {
return new Response(JSON.stringify(body), { headers: {
"content-type": "application/json",
"cache-control": "no-store"
} });
}
/**
* The request-free core of {@link reconstructGeneration}: read the last
* generation run for a thread (or a specific run) straight from the
* `generationRuns` store and return the plain `{ resumeSnapshot, activeRun }`
* hydration payload a server-authoritative client adopts on mount.
*
* Use this from a TanStack Start server function (or any direct call) to back
* a client's `hydrateGeneration` handler without fabricating a `Request`:
*
* ```ts
* async function loadImageHydration({ data: threadId }: { data: string }) {
* // Do your own auth here — this helper does not enforce tenancy.
* return await getGenerationHydration(persistence, threadId)
* }
* ```
*
* Wire that body up as the server function's handler — build it with
* `createServerFn({ method: 'GET' })`, add an `inputValidator` that returns
* the thread id, then hand it the function above. (The chained call is shown
* split apart on purpose: Start's server-fn plugin decides which modules to
* transform by scanning source text for that call, and a package whose shipped
* comments contain it gets pulled into the transform.)
*
* ⚠️ Unlike {@link reconstructGeneration} this helper takes **no** `authorize`
* option — there is no `Request` to authorize against. Server-function callers
* must gate the call themselves (session → owned thread/run) before resolving
* the id, or any caller who guesses an id receives the run's status and result
* metadata.
*
* Returns `{ resumeSnapshot: null, activeRun: null }` when `id` is empty or no
* matching run exists, so the caller never has to special-case a first load.
*/
async function getGenerationHydration(persistence, id, options) {
validateReconstructGenerationStores(persistence);
const runStore = persistence.stores.generationRuns;
if (!runStore) throw new Error("getGenerationHydration requires stores.generationRuns.");
if (!id) return {
resumeSnapshot: null,
activeRun: null
};
const run = options?.by === "runId" ? await runStore.get(id) : await runStore.findLatestForThread(id);
if (!run) return {
resumeSnapshot: null,
activeRun: null
};
return {
resumeSnapshot: runToSnapshot(run),
activeRun: run.status === "running" ? { runId: run.runId } : null
};
}
/**
* Build the JSON `Response` a server-authoritative client hydrates a generation
* from on load. Reads a `?runId=` (preferred) or `?threadId=` from the request
* query and returns `{ resumeSnapshot, activeRun }`
* ({@link ReconstructedGeneration}):
*
* - Resolves the run by `runId` via `stores.generationRuns.get`, else the
* latest run filed under `threadId` via the required
* `stores.generationRuns.findLatestForThread`.
* - `resumeSnapshot` — the run mapped to a client snapshot (status, result,
* error, activity, and a `resumeState` cursor while still running), or `null`.
* - `activeRun` — `{ runId }` when the run is still generating, else `null`.
*
* Requires `stores.generationRuns`. Returns
* `{ resumeSnapshot: null, activeRun: null }` when no id is supplied or no
* matching run exists, so the caller never has to special-case a first load.
*
* This helper does **not** enforce tenancy by itself. Pass
* {@link ReconstructGenerationOptions.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 reconstructGeneration(persistence, request, {
* authorize: async (id, req) => {
* const userId = await getSessionUserId(req)
* return userId != null && (await userOwnsThread(userId, id))
* },
* })
* }
* ```
*/
async function reconstructGeneration(persistence, request, options) {
const params = new URL(request.url).searchParams;
const runParam = options?.runParam ?? "runId";
const threadParam = options?.param ?? "threadId";
const runId = params.get(runParam) ?? "";
const threadId = params.get(threadParam) ?? "";
const id = runId || threadId;
if (!id) return jsonResponse({
resumeSnapshot: null,
activeRun: null
});
if (options?.authorize) {
const decision = await options.authorize(id, 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"
}
});
}
return jsonResponse(await getGenerationHydration(persistence, id, { by: runId ? "runId" : "threadId" }));
}
//#endregion
export { getGenerationHydration, reconstructGeneration };
//# sourceMappingURL=reconstruct-generation.js.map