@tanstack/ai-persistence 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/esm/blob-range.d.ts +51 -0
  2. package/dist/esm/blob-range.js +84 -0
  3. package/dist/esm/blob-range.js.map +1 -0
  4. package/dist/esm/capabilities.d.ts +5 -0
  5. package/dist/esm/capabilities.js +16 -0
  6. package/dist/esm/capabilities.js.map +1 -0
  7. package/dist/esm/index.d.ts +13 -0
  8. package/dist/esm/index.js +9 -0
  9. package/dist/esm/memory.d.ts +19 -0
  10. package/dist/esm/memory.js +319 -0
  11. package/dist/esm/memory.js.map +1 -0
  12. package/dist/esm/middleware.d.ts +252 -0
  13. package/dist/esm/middleware.js +872 -0
  14. package/dist/esm/middleware.js.map +1 -0
  15. package/dist/esm/reconstruct-generation.d.ts +129 -0
  16. package/dist/esm/reconstruct-generation.js +148 -0
  17. package/dist/esm/reconstruct-generation.js.map +1 -0
  18. package/dist/esm/reconstruct.d.ts +79 -0
  19. package/dist/esm/reconstruct.js +75 -0
  20. package/dist/esm/reconstruct.js.map +1 -0
  21. package/dist/esm/retrieve.d.ts +40 -0
  22. package/dist/esm/retrieve.js +54 -0
  23. package/dist/esm/retrieve.js.map +1 -0
  24. package/dist/esm/testkit/conformance.d.ts +33 -0
  25. package/dist/esm/testkit/conformance.js +997 -0
  26. package/dist/esm/testkit/conformance.js.map +1 -0
  27. package/dist/esm/types.d.ts +554 -0
  28. package/dist/esm/types.js +103 -0
  29. package/dist/esm/types.js.map +1 -0
  30. package/package.json +71 -0
  31. package/skills/ai-persistence/SKILL.md +218 -0
  32. package/skills/ai-persistence/build-cloudflare-adapter/SKILL.md +313 -0
  33. package/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md +693 -0
  34. package/skills/ai-persistence/build-custom-adapter/SKILL.md +328 -0
  35. package/skills/ai-persistence/build-drizzle-adapter/SKILL.md +562 -0
  36. package/skills/ai-persistence/build-prisma-adapter/SKILL.md +518 -0
  37. package/skills/ai-persistence/server/SKILL.md +210 -0
  38. package/skills/ai-persistence/stores/SKILL.md +485 -0
  39. package/src/blob-range.ts +101 -0
  40. package/src/capabilities.ts +18 -0
  41. package/src/index.ts +114 -0
  42. package/src/memory.ts +491 -0
  43. package/src/middleware.ts +1795 -0
  44. package/src/reconstruct-generation.ts +244 -0
  45. package/src/reconstruct.ts +149 -0
  46. package/src/retrieve.ts +77 -0
  47. package/src/testkit/conformance.ts +1288 -0
  48. package/src/types.ts +878 -0
@@ -0,0 +1,244 @@
1
+ import { validateReconstructGenerationStores } from './types'
2
+ import type { AIPersistence, GenerationRunRecord } from './types'
3
+
4
+ /**
5
+ * The JSON body `reconstructGeneration` returns and a server-authoritative
6
+ * client hydrates from on mount.
7
+ *
8
+ * `resumeSnapshot` mirrors the last generation run for the requested thread (or
9
+ * a specific run id): its terminal/running `status`, the `result` metadata and
10
+ * `error` it recorded, the `activity` it ran, and a `resumeState` cursor
11
+ * (present only while the run is still `running`) the client can use to tail the
12
+ * live generation. `null` when there is no matching run.
13
+ *
14
+ * `activeRun` is `{ runId }` when the resolved run is still `running`, else
15
+ * `null` — the parallel of {@link ReconstructedChat.activeRun}.
16
+ */
17
+ export interface ReconstructedGeneration {
18
+ resumeSnapshot: {
19
+ schemaVersion: 1
20
+ resumeState: { threadId: string; runId: string } | null
21
+ status: 'idle' | 'running' | 'complete' | 'error'
22
+ result?: unknown
23
+ error?: { message: string; code?: string }
24
+ activity?: string
25
+ } | null
26
+ activeRun: { runId: string } | null
27
+ }
28
+
29
+ export interface ReconstructGenerationOptions {
30
+ /** Query parameter carrying the thread id. Defaults to `threadId`. */
31
+ param?: string
32
+ /** Query parameter carrying the run id. Defaults to `runId`. */
33
+ runParam?: string
34
+ /**
35
+ * Authorize access to the requested generation before loading it.
36
+ *
37
+ * ⚠️ Without this, any caller who knows or guesses `?threadId=` / `?runId=`
38
+ * receives the generation's status and result metadata. Multi-user /
39
+ * multi-tenant deployments **must** supply an authorization check (session →
40
+ * owned thread/run) or resolve a validated id in the route.
41
+ *
42
+ * Called with whichever id was supplied — the `runId` when present, else the
43
+ * `threadId`. Return:
44
+ * - `true` to allow the load
45
+ * - `false` for a default `403` response
46
+ * - a `Response` to return as-is (e.g. `401` with a body)
47
+ */
48
+ authorize?: (
49
+ id: string,
50
+ request: Request,
51
+ ) => boolean | Response | Promise<boolean | Response>
52
+ }
53
+
54
+ /**
55
+ * Map the persisted run status to the client-facing resume-snapshot status.
56
+ * An `interrupted` or `aborted` run surfaces as `error` — the client has no live
57
+ * run to resume, and neither produced a usable result. (A generation abort is
58
+ * always terminal: there is no journal to reattach to, so `withGenerationPersistence`
59
+ * writes `'aborted'` rather than parking the run.)
60
+ */
61
+ function snapshotStatus(
62
+ status: GenerationRunRecord['status'],
63
+ ): 'running' | 'complete' | 'error' {
64
+ switch (status) {
65
+ case 'running':
66
+ return 'running'
67
+ case 'completed':
68
+ return 'complete'
69
+ case 'failed':
70
+ case 'interrupted':
71
+ case 'aborted':
72
+ return 'error'
73
+ }
74
+ }
75
+
76
+ function runToSnapshot(
77
+ run: GenerationRunRecord,
78
+ ): NonNullable<ReconstructedGeneration['resumeSnapshot']> {
79
+ const status = snapshotStatus(run.status)
80
+ return {
81
+ schemaVersion: 1,
82
+ resumeState:
83
+ status === 'running'
84
+ ? { runId: run.runId, threadId: run.threadId }
85
+ : null,
86
+ status,
87
+ ...(run.result !== undefined ? { result: run.result } : {}),
88
+ ...(run.error !== undefined ? { error: run.error } : {}),
89
+ ...(run.activity !== undefined ? { activity: run.activity } : {}),
90
+ }
91
+ }
92
+
93
+ function jsonResponse(body: ReconstructedGeneration): Response {
94
+ return new Response(JSON.stringify(body), {
95
+ headers: {
96
+ 'content-type': 'application/json',
97
+ 'cache-control': 'no-store',
98
+ },
99
+ })
100
+ }
101
+
102
+ export interface GetGenerationHydrationOptions {
103
+ /**
104
+ * How to interpret `id`:
105
+ * - `'runId'` loads exactly that run via `stores.generationRuns.get`.
106
+ * - `'threadId'` (default) loads the latest run linked to the thread via
107
+ * `stores.generationRuns.findLatestForThread`.
108
+ */
109
+ by?: 'threadId' | 'runId'
110
+ }
111
+
112
+ /**
113
+ * The request-free core of {@link reconstructGeneration}: read the last
114
+ * generation run for a thread (or a specific run) straight from the
115
+ * `generationRuns` store and return the plain `{ resumeSnapshot, activeRun }`
116
+ * hydration payload a server-authoritative client adopts on mount.
117
+ *
118
+ * Use this from a TanStack Start server function (or any direct call) to back
119
+ * a client's `hydrateGeneration` handler without fabricating a `Request`:
120
+ *
121
+ * ```ts
122
+ * async function loadImageHydration({ data: threadId }: { data: string }) {
123
+ * // Do your own auth here — this helper does not enforce tenancy.
124
+ * return await getGenerationHydration(persistence, threadId)
125
+ * }
126
+ * ```
127
+ *
128
+ * Wire that body up as the server function's handler — build it with
129
+ * `createServerFn({ method: 'GET' })`, add an `inputValidator` that returns
130
+ * the thread id, then hand it the function above. (The chained call is shown
131
+ * split apart on purpose: Start's server-fn plugin decides which modules to
132
+ * transform by scanning source text for that call, and a package whose shipped
133
+ * comments contain it gets pulled into the transform.)
134
+ *
135
+ * ⚠️ Unlike {@link reconstructGeneration} this helper takes **no** `authorize`
136
+ * option — there is no `Request` to authorize against. Server-function callers
137
+ * must gate the call themselves (session → owned thread/run) before resolving
138
+ * the id, or any caller who guesses an id receives the run's status and result
139
+ * metadata.
140
+ *
141
+ * Returns `{ resumeSnapshot: null, activeRun: null }` when `id` is empty or no
142
+ * matching run exists, so the caller never has to special-case a first load.
143
+ */
144
+ export async function getGenerationHydration(
145
+ persistence: AIPersistence,
146
+ id: string,
147
+ options?: GetGenerationHydrationOptions,
148
+ ): Promise<ReconstructedGeneration> {
149
+ validateReconstructGenerationStores(persistence)
150
+ const runStore = persistence.stores.generationRuns
151
+ if (!runStore) {
152
+ // validateReconstructGenerationStores already throws; this narrows for TS.
153
+ throw new Error('getGenerationHydration requires stores.generationRuns.')
154
+ }
155
+
156
+ if (!id) {
157
+ return { resumeSnapshot: null, activeRun: null }
158
+ }
159
+
160
+ const run =
161
+ options?.by === 'runId'
162
+ ? await runStore.get(id)
163
+ : await runStore.findLatestForThread(id)
164
+
165
+ if (!run) {
166
+ return { resumeSnapshot: null, activeRun: null }
167
+ }
168
+
169
+ return {
170
+ resumeSnapshot: runToSnapshot(run),
171
+ activeRun: run.status === 'running' ? { runId: run.runId } : null,
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Build the JSON `Response` a server-authoritative client hydrates a generation
177
+ * from on load. Reads a `?runId=` (preferred) or `?threadId=` from the request
178
+ * query and returns `{ resumeSnapshot, activeRun }`
179
+ * ({@link ReconstructedGeneration}):
180
+ *
181
+ * - Resolves the run by `runId` via `stores.generationRuns.get`, else the
182
+ * latest run filed under `threadId` via the required
183
+ * `stores.generationRuns.findLatestForThread`.
184
+ * - `resumeSnapshot` — the run mapped to a client snapshot (status, result,
185
+ * error, activity, and a `resumeState` cursor while still running), or `null`.
186
+ * - `activeRun` — `{ runId }` when the run is still generating, else `null`.
187
+ *
188
+ * Requires `stores.generationRuns`. Returns
189
+ * `{ resumeSnapshot: null, activeRun: null }` when no id is supplied or no
190
+ * matching run exists, so the caller never has to special-case a first load.
191
+ *
192
+ * This helper does **not** enforce tenancy by itself. Pass
193
+ * {@link ReconstructGenerationOptions.authorize} (or wrap the call in your own
194
+ * session gate) before exposing it on a public route.
195
+ *
196
+ * ```ts
197
+ * export async function GET(request: Request) {
198
+ * return reconstructGeneration(persistence, request, {
199
+ * authorize: async (id, req) => {
200
+ * const userId = await getSessionUserId(req)
201
+ * return userId != null && (await userOwnsThread(userId, id))
202
+ * },
203
+ * })
204
+ * }
205
+ * ```
206
+ */
207
+ export async function reconstructGeneration(
208
+ persistence: AIPersistence,
209
+ request: Request,
210
+ options?: ReconstructGenerationOptions,
211
+ ): Promise<Response> {
212
+ const params = new URL(request.url).searchParams
213
+ const runParam = options?.runParam ?? 'runId'
214
+ const threadParam = options?.param ?? 'threadId'
215
+ const runId = params.get(runParam) ?? ''
216
+ const threadId = params.get(threadParam) ?? ''
217
+
218
+ const id = runId || threadId
219
+ if (!id) {
220
+ return jsonResponse({ resumeSnapshot: null, activeRun: null })
221
+ }
222
+
223
+ if (options?.authorize) {
224
+ const decision = await options.authorize(id, request)
225
+ if (decision instanceof Response) {
226
+ return decision
227
+ }
228
+ if (!decision) {
229
+ return new Response(JSON.stringify({ error: 'Forbidden' }), {
230
+ status: 403,
231
+ headers: {
232
+ 'content-type': 'application/json',
233
+ 'cache-control': 'no-store',
234
+ },
235
+ })
236
+ }
237
+ }
238
+
239
+ return jsonResponse(
240
+ await getGenerationHydration(persistence, id, {
241
+ by: runId ? 'runId' : 'threadId',
242
+ }),
243
+ )
244
+ }
@@ -0,0 +1,149 @@
1
+ import { modelMessagesToUIMessages } from '@tanstack/ai'
2
+ import type { UIMessage } from '@tanstack/ai'
3
+ import { validateReconstructChatStores } from './types'
4
+ import type { AIPersistence, ChatTranscriptStores } from './types'
5
+
6
+ /**
7
+ * The JSON body `reconstructChat` returns and a server-authoritative client
8
+ * hydrates from on mount.
9
+ *
10
+ * `messages` is the stored transcript as UI messages (ready to paint).
11
+ * `activeRun` is a cursor to a run still generating for the thread, or `null` —
12
+ * resolved from the STABLE thread id via `stores.runs.findActiveRun`, so the
13
+ * client learns "there is a live run to tail" without ever handling a run id.
14
+ * `interrupts` is the thread's pending human-in-the-loop interrupts (tool
15
+ * approvals, client-tool/generic waits) and the run they paused, or `null` —
16
+ * so a reload (or another device) re-prompts the approval from the SERVER, not
17
+ * from client storage. Resolved via `stores.interrupts.listPending`.
18
+ */
19
+ export interface ReconstructedChat {
20
+ messages: Array<UIMessage>
21
+ activeRun: { runId: string } | null
22
+ interrupts: {
23
+ runId: string
24
+ pending: Array<Record<string, unknown>>
25
+ } | null
26
+ }
27
+
28
+ export interface ReconstructChatOptions {
29
+ /** Query parameter carrying the thread id. Defaults to `threadId`. */
30
+ param?: string
31
+ /**
32
+ * Authorize access to the requested thread before loading history.
33
+ *
34
+ * ⚠️ Without this, any caller who knows or guesses `?threadId=` receives the
35
+ * full transcript. Multi-user / multi-tenant deployments **must** supply
36
+ * an authorization check (session → owned threads) or resolve a validated
37
+ * thread id in the route and pass it via a custom `param` that only your
38
+ * server sets.
39
+ *
40
+ * Return:
41
+ * - `true` to allow the load
42
+ * - `false` for a default `403` response
43
+ * - a `Response` to return as-is (e.g. `401` with a body)
44
+ */
45
+ authorize?: (
46
+ threadId: string,
47
+ request: Request,
48
+ ) => boolean | Response | Promise<boolean | Response>
49
+ }
50
+
51
+ /**
52
+ * Build the JSON `Response` a server-authoritative client hydrates from on load
53
+ * (see the client-persistence guide). Reads the thread id from the request query
54
+ * (`?threadId=` by default) and returns `{ messages, activeRun, interrupts }`
55
+ * ({@link ReconstructedChat}):
56
+ *
57
+ * - `messages` — the stored transcript as UI messages.
58
+ * - `activeRun` — `{ runId }` if a run is still generating for the thread (so the
59
+ * client tails it via the durability stream), else `null`. Resolved via the
60
+ * required `stores.runs.findActiveRun`; `null` when the `runs` store is absent.
61
+ * - `interrupts` — `{ runId, pending }` if the thread has pending human-in-the-loop
62
+ * interrupts (a paused approval / wait) and the run they paused, else `null`, so
63
+ * a reload re-prompts the decision from the server. Resolved via the optional
64
+ * `stores.interrupts.listPending`; `null` when that store is absent.
65
+ *
66
+ * Requires `stores.messages`. Returns an empty transcript with no active run
67
+ * and no interrupts when the thread id is missing or the thread is unknown, so
68
+ * the caller never has to special-case a first load.
69
+ *
70
+ * This helper does **not** enforce tenancy by itself. Pass
71
+ * {@link ReconstructChatOptions.authorize} (or wrap the call in your own
72
+ * session gate) before exposing it on a public route.
73
+ *
74
+ * ```ts
75
+ * export async function GET(request: Request) {
76
+ * return reconstructChat(persistence, request, {
77
+ * authorize: async (threadId, req) => {
78
+ * const userId = await getSessionUserId(req)
79
+ * return userId != null && (await userOwnsThread(userId, threadId))
80
+ * },
81
+ * })
82
+ * }
83
+ * ```
84
+ */
85
+ export async function reconstructChat(
86
+ persistence: AIPersistence<ChatTranscriptStores>,
87
+ request: Request,
88
+ options?: ReconstructChatOptions,
89
+ ): Promise<Response> {
90
+ validateReconstructChatStores(persistence)
91
+ const messageStore = persistence.stores.messages
92
+ if (!messageStore) {
93
+ // validateReconstructChatStores already throws; this narrows for TypeScript.
94
+ throw new Error('reconstructChat requires stores.messages.')
95
+ }
96
+
97
+ const param = options?.param ?? 'threadId'
98
+ const threadId = new URL(request.url).searchParams.get(param) ?? ''
99
+
100
+ if (threadId && options?.authorize) {
101
+ const decision = await options.authorize(threadId, request)
102
+ if (decision instanceof Response) {
103
+ return decision
104
+ }
105
+ if (!decision) {
106
+ return new Response(JSON.stringify({ error: 'Forbidden' }), {
107
+ status: 403,
108
+ headers: {
109
+ 'content-type': 'application/json',
110
+ 'cache-control': 'no-store',
111
+ },
112
+ })
113
+ }
114
+ }
115
+
116
+ // Resolve the active run BEFORE reading the transcript. `withPersistence`
117
+ // persists the final transcript BEFORE marking a run complete, so observing
118
+ // "no active run" here guarantees the transcript read below is the FINAL one.
119
+ // Reading them in the other order opens a finish-window race: a fast run that
120
+ // completes between the two reads would return a stale streaming snapshot with
121
+ // `activeRun: null`, leaving the client stuck on the partial (no run to tail).
122
+ const active = threadId
123
+ ? await persistence.stores.runs?.findActiveRun(threadId)
124
+ : null
125
+ const stored = threadId ? await messageStore.loadThread(threadId) : []
126
+ // Pending interrupts for the thread, so a reload re-prompts the approval from
127
+ // the server. Each stored `payload` is the full interrupt descriptor the
128
+ // client hydrates; they share the run they paused.
129
+ const pending = threadId
130
+ ? ((await persistence.stores.interrupts?.listPending(threadId)) ?? [])
131
+ : []
132
+ const firstPending = pending[0]
133
+ const body: ReconstructedChat = {
134
+ messages: modelMessagesToUIMessages(stored),
135
+ activeRun: active ? { runId: active.runId } : null,
136
+ interrupts: firstPending
137
+ ? {
138
+ runId: firstPending.runId,
139
+ pending: pending.map((record) => record.payload),
140
+ }
141
+ : null,
142
+ }
143
+ return new Response(JSON.stringify(body), {
144
+ headers: {
145
+ 'content-type': 'application/json',
146
+ 'cache-control': 'no-store',
147
+ },
148
+ })
149
+ }
@@ -0,0 +1,77 @@
1
+ import type {
2
+ AIPersistence,
3
+ ArtifactRecord,
4
+ BlobGetOptions,
5
+ BlobObject,
6
+ } from './types'
7
+
8
+ /**
9
+ * The DEFAULT blob-store key a generation artifact's bytes are stored under,
10
+ * used when `withGenerationPersistence` is given no `storageKey` mapper.
11
+ *
12
+ * Reads go through {@link resolveArtifactBlobKey} instead: a record written with
13
+ * a custom `storageKey` carries its real key in `blobKey`, and recomputing the
14
+ * default would look in the wrong place.
15
+ *
16
+ * @internal
17
+ */
18
+ export function artifactBlobKey(
19
+ ref: Pick<ArtifactRecord, 'runId' | 'artifactId'>,
20
+ ): string {
21
+ return `artifacts/${ref.runId}/${ref.artifactId}`
22
+ }
23
+
24
+ /**
25
+ * The blob-store key to read an artifact's bytes from: the key recorded when it
26
+ * was written, falling back to the default convention for records written
27
+ * before `blobKey` existed.
28
+ *
29
+ * The fallback is what makes `blobKey` a non-breaking addition — and why the
30
+ * default convention can never be changed retroactively without one.
31
+ */
32
+ export function resolveArtifactBlobKey(record: ArtifactRecord): string {
33
+ return record.blobKey ?? artifactBlobKey(record)
34
+ }
35
+
36
+ /**
37
+ * Look up a persisted generation artifact's metadata by id. Returns `null` when
38
+ * the persistence has no `artifacts` store or no record matches — so a serve
39
+ * handler can map that straight to a 404.
40
+ */
41
+ export async function retrieveArtifact(
42
+ persistence: AIPersistence,
43
+ artifactId: string,
44
+ ): Promise<ArtifactRecord | null> {
45
+ const record = await persistence.stores.artifacts?.get(artifactId)
46
+ return record ?? null
47
+ }
48
+
49
+ /**
50
+ * Look up a persisted generation artifact's stored bytes. Pass an `artifactId`
51
+ * (resolved to its record first) or an already-loaded {@link ArtifactRecord}
52
+ * (no second metadata lookup). Returns `null` when the artifact, its record, or
53
+ * its blob is missing, or the stores are not configured.
54
+ *
55
+ * Pass `options.range` to read one slice — how a serve route answers a `Range`
56
+ * request with `206` + `Content-Range` instead of the whole file, which is what
57
+ * `<video>` seeking is built on. Resolve the range against `record.size` and
58
+ * reply `416` yourself when it does not fit; the store is handed satisfiable
59
+ * ranges only. The returned object's `range` reports the slice actually served.
60
+ */
61
+ export async function retrieveBlob(
62
+ persistence: AIPersistence,
63
+ artifact: string | ArtifactRecord,
64
+ options?: BlobGetOptions,
65
+ ): Promise<BlobObject | null> {
66
+ const record =
67
+ typeof artifact === 'string'
68
+ ? await retrieveArtifact(persistence, artifact)
69
+ : artifact
70
+ if (!record) return null
71
+
72
+ const blob = await persistence.stores.blobs?.get(
73
+ resolveArtifactBlobKey(record),
74
+ options,
75
+ )
76
+ return blob ?? null
77
+ }