@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.
- package/dist/esm/blob-range.d.ts +51 -0
- package/dist/esm/blob-range.js +84 -0
- package/dist/esm/blob-range.js.map +1 -0
- package/dist/esm/capabilities.d.ts +5 -0
- package/dist/esm/capabilities.js +16 -0
- package/dist/esm/capabilities.js.map +1 -0
- package/dist/esm/index.d.ts +13 -0
- package/dist/esm/index.js +9 -0
- package/dist/esm/memory.d.ts +19 -0
- package/dist/esm/memory.js +319 -0
- package/dist/esm/memory.js.map +1 -0
- package/dist/esm/middleware.d.ts +252 -0
- package/dist/esm/middleware.js +872 -0
- package/dist/esm/middleware.js.map +1 -0
- package/dist/esm/reconstruct-generation.d.ts +129 -0
- package/dist/esm/reconstruct-generation.js +148 -0
- package/dist/esm/reconstruct-generation.js.map +1 -0
- package/dist/esm/reconstruct.d.ts +79 -0
- package/dist/esm/reconstruct.js +75 -0
- package/dist/esm/reconstruct.js.map +1 -0
- package/dist/esm/retrieve.d.ts +40 -0
- package/dist/esm/retrieve.js +54 -0
- package/dist/esm/retrieve.js.map +1 -0
- package/dist/esm/testkit/conformance.d.ts +33 -0
- package/dist/esm/testkit/conformance.js +997 -0
- package/dist/esm/testkit/conformance.js.map +1 -0
- package/dist/esm/types.d.ts +554 -0
- package/dist/esm/types.js +103 -0
- package/dist/esm/types.js.map +1 -0
- package/package.json +71 -0
- package/skills/ai-persistence/SKILL.md +218 -0
- package/skills/ai-persistence/build-cloudflare-adapter/SKILL.md +313 -0
- package/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md +693 -0
- package/skills/ai-persistence/build-custom-adapter/SKILL.md +328 -0
- package/skills/ai-persistence/build-drizzle-adapter/SKILL.md +562 -0
- package/skills/ai-persistence/build-prisma-adapter/SKILL.md +518 -0
- package/skills/ai-persistence/server/SKILL.md +210 -0
- package/skills/ai-persistence/stores/SKILL.md +485 -0
- package/src/blob-range.ts +101 -0
- package/src/capabilities.ts +18 -0
- package/src/index.ts +114 -0
- package/src/memory.ts +491 -0
- package/src/middleware.ts +1795 -0
- package/src/reconstruct-generation.ts +244 -0
- package/src/reconstruct.ts +149 -0
- package/src/retrieve.ts +77 -0
- package/src/testkit/conformance.ts +1288 -0
- 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
|
+
}
|
package/src/retrieve.ts
ADDED
|
@@ -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
|
+
}
|