@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
package/src/index.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Store contracts + named chat shapes
|
|
2
|
+
export {
|
|
3
|
+
composePersistence,
|
|
4
|
+
defineAIPersistence,
|
|
5
|
+
defineMessageStore,
|
|
6
|
+
defineRunStore,
|
|
7
|
+
defineInterruptStore,
|
|
8
|
+
defineMetadataStore,
|
|
9
|
+
defineGenerationRunStore,
|
|
10
|
+
defineArtifactStore,
|
|
11
|
+
defineBlobStore,
|
|
12
|
+
// Run lifecycle helpers owned by @tanstack/ai, transiting through ./types so
|
|
13
|
+
// this package's public surface stays a single import for backend authors.
|
|
14
|
+
isTerminalRunStatus,
|
|
15
|
+
} from './types'
|
|
16
|
+
export type {
|
|
17
|
+
MessageStore,
|
|
18
|
+
RunStatus,
|
|
19
|
+
TerminalRunStatus,
|
|
20
|
+
RunRecord,
|
|
21
|
+
RunStore,
|
|
22
|
+
InterruptRecord,
|
|
23
|
+
InterruptStatus,
|
|
24
|
+
InterruptStore,
|
|
25
|
+
MetadataStore,
|
|
26
|
+
// Named product shapes (prefer these over a sparse bag)
|
|
27
|
+
ChatTranscriptStores,
|
|
28
|
+
ChatPersistenceStores,
|
|
29
|
+
ChatWithInterruptsStores,
|
|
30
|
+
ChatTranscriptPersistence,
|
|
31
|
+
ChatPersistence,
|
|
32
|
+
ChatWithInterruptsPersistence,
|
|
33
|
+
// Generation run store contract
|
|
34
|
+
GenerationRunStatus,
|
|
35
|
+
GenerationRunRecord,
|
|
36
|
+
GenerationRunStore,
|
|
37
|
+
// Generation artifact + blob store contracts
|
|
38
|
+
ArtifactRecord,
|
|
39
|
+
ArtifactStore,
|
|
40
|
+
BlobBody,
|
|
41
|
+
BlobRecord,
|
|
42
|
+
BlobObject,
|
|
43
|
+
BlobListPage,
|
|
44
|
+
BlobPutOptions,
|
|
45
|
+
BlobGetOptions,
|
|
46
|
+
BlobRange,
|
|
47
|
+
BlobListOptions,
|
|
48
|
+
BlobStore,
|
|
49
|
+
AIPersistence,
|
|
50
|
+
AIPersistenceOverrides,
|
|
51
|
+
ComposedAIPersistenceStores,
|
|
52
|
+
// Shared conversation identity from @tanstack/ai. Stores key on
|
|
53
|
+
// Scope.threadId; authorize multi-user access with Scope.userId/tenantId.
|
|
54
|
+
Scope,
|
|
55
|
+
} from './types'
|
|
56
|
+
// AIPersistenceStores is intentionally NOT re-exported — use a named chat
|
|
57
|
+
// shape or AIPersistence<{ messages: MessageStore, … }>.
|
|
58
|
+
|
|
59
|
+
// Core artifact wire types (re-exported for convenience)
|
|
60
|
+
export type {
|
|
61
|
+
PersistedArtifactActivity,
|
|
62
|
+
PersistedArtifactRef,
|
|
63
|
+
PersistedArtifactRole,
|
|
64
|
+
} from '@tanstack/ai'
|
|
65
|
+
|
|
66
|
+
// Middleware (chat state — locks live in @tanstack/ai as withLocks)
|
|
67
|
+
export { withPersistence, withGenerationPersistence } from './middleware'
|
|
68
|
+
export type {
|
|
69
|
+
WithPersistenceOptions,
|
|
70
|
+
WithGenerationPersistenceOptions,
|
|
71
|
+
ArtifactPersistenceOptions,
|
|
72
|
+
GenerationArtifactDescriptor,
|
|
73
|
+
GenerationArtifactExtractionInput,
|
|
74
|
+
GenerationArtifactNameInput,
|
|
75
|
+
} from './middleware'
|
|
76
|
+
|
|
77
|
+
// Server helper: rehydrate a thread's messages for a client load
|
|
78
|
+
export { reconstructChat } from './reconstruct'
|
|
79
|
+
export type { ReconstructChatOptions } from './reconstruct'
|
|
80
|
+
|
|
81
|
+
// Server helper: rehydrate the last generation job for a client load
|
|
82
|
+
export {
|
|
83
|
+
reconstructGeneration,
|
|
84
|
+
getGenerationHydration,
|
|
85
|
+
} from './reconstruct-generation'
|
|
86
|
+
export type {
|
|
87
|
+
ReconstructedGeneration,
|
|
88
|
+
ReconstructGenerationOptions,
|
|
89
|
+
GetGenerationHydrationOptions,
|
|
90
|
+
} from './reconstruct-generation'
|
|
91
|
+
|
|
92
|
+
// Server helpers: retrieve a persisted generation artifact + its bytes
|
|
93
|
+
export {
|
|
94
|
+
retrieveArtifact,
|
|
95
|
+
retrieveBlob,
|
|
96
|
+
resolveArtifactBlobKey,
|
|
97
|
+
} from './retrieve'
|
|
98
|
+
|
|
99
|
+
// Byte-range helpers: parse a `Range` header (routes), clamp a range to an
|
|
100
|
+
// object's real size (store authors)
|
|
101
|
+
export { parseRangeHeader, resolveBlobRange } from './blob-range'
|
|
102
|
+
|
|
103
|
+
// Reference in-memory implementation
|
|
104
|
+
export { memoryPersistence } from './memory'
|
|
105
|
+
|
|
106
|
+
// Persistence-owned capabilities only. Locks: @tanstack/ai.
|
|
107
|
+
export {
|
|
108
|
+
PersistenceCapability,
|
|
109
|
+
InterruptsCapability,
|
|
110
|
+
getPersistence,
|
|
111
|
+
providePersistence,
|
|
112
|
+
getInterrupts,
|
|
113
|
+
provideInterrupts,
|
|
114
|
+
} from './capabilities'
|
package/src/memory.ts
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import { defineAIPersistence } from './types'
|
|
2
|
+
import { resolveBlobRange } from './blob-range'
|
|
3
|
+
import type { ModelMessage } from '@tanstack/ai'
|
|
4
|
+
import type {
|
|
5
|
+
ArtifactRecord,
|
|
6
|
+
ArtifactStore,
|
|
7
|
+
BlobBody,
|
|
8
|
+
BlobGetOptions,
|
|
9
|
+
BlobListOptions,
|
|
10
|
+
BlobObject,
|
|
11
|
+
BlobPutOptions,
|
|
12
|
+
BlobRange,
|
|
13
|
+
BlobRecord,
|
|
14
|
+
BlobStore,
|
|
15
|
+
GenerationRunRecord,
|
|
16
|
+
GenerationRunStore,
|
|
17
|
+
InterruptRecord,
|
|
18
|
+
InterruptStore,
|
|
19
|
+
MessageStore,
|
|
20
|
+
MetadataStore,
|
|
21
|
+
RunRecord,
|
|
22
|
+
RunStore,
|
|
23
|
+
} from './types'
|
|
24
|
+
|
|
25
|
+
class MemoryMessageStore implements MessageStore {
|
|
26
|
+
private readonly threads = new Map<string, Array<ModelMessage>>()
|
|
27
|
+
loadThread(threadId: string): Promise<Array<ModelMessage>> {
|
|
28
|
+
return Promise.resolve(this.threads.get(threadId)?.slice() ?? [])
|
|
29
|
+
}
|
|
30
|
+
saveThread(threadId: string, messages: Array<ModelMessage>): Promise<void> {
|
|
31
|
+
this.threads.set(threadId, messages.slice())
|
|
32
|
+
return Promise.resolve()
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
class MemoryRunStore implements RunStore {
|
|
37
|
+
private readonly runs = new Map<string, RunRecord>()
|
|
38
|
+
createOrResume(input: {
|
|
39
|
+
runId: string
|
|
40
|
+
threadId: string
|
|
41
|
+
status?: RunRecord['status']
|
|
42
|
+
startedAt: number
|
|
43
|
+
}): Promise<RunRecord> {
|
|
44
|
+
const existing = this.runs.get(input.runId)
|
|
45
|
+
if (existing) return Promise.resolve(existing)
|
|
46
|
+
const record: RunRecord = {
|
|
47
|
+
runId: input.runId,
|
|
48
|
+
threadId: input.threadId,
|
|
49
|
+
status: input.status ?? 'running',
|
|
50
|
+
startedAt: input.startedAt,
|
|
51
|
+
}
|
|
52
|
+
this.runs.set(record.runId, record)
|
|
53
|
+
return Promise.resolve(record)
|
|
54
|
+
}
|
|
55
|
+
update(
|
|
56
|
+
runId: string,
|
|
57
|
+
patch: Partial<
|
|
58
|
+
Pick<
|
|
59
|
+
RunRecord,
|
|
60
|
+
| 'status'
|
|
61
|
+
| 'finishedAt'
|
|
62
|
+
| 'error'
|
|
63
|
+
| 'usage'
|
|
64
|
+
| 'sandboxKey'
|
|
65
|
+
| 'detachedSince'
|
|
66
|
+
| 'cancelRequested'
|
|
67
|
+
| 'driverEpoch'
|
|
68
|
+
>
|
|
69
|
+
>,
|
|
70
|
+
): Promise<void> {
|
|
71
|
+
const existing = this.runs.get(runId)
|
|
72
|
+
if (existing) this.runs.set(runId, { ...existing, ...patch })
|
|
73
|
+
return Promise.resolve()
|
|
74
|
+
}
|
|
75
|
+
get(runId: string): Promise<RunRecord | null> {
|
|
76
|
+
return Promise.resolve(this.runs.get(runId) ?? null)
|
|
77
|
+
}
|
|
78
|
+
findActiveRun(threadId: string): Promise<RunRecord | null> {
|
|
79
|
+
const active = [...this.runs.values()]
|
|
80
|
+
.filter((run) => run.threadId === threadId && run.status === 'running')
|
|
81
|
+
.sort((a, b) => b.startedAt - a.startedAt)
|
|
82
|
+
return Promise.resolve(active[0] ?? null)
|
|
83
|
+
}
|
|
84
|
+
listByThread(threadId: string): Promise<Array<RunRecord>> {
|
|
85
|
+
const matching = [...this.runs.values()]
|
|
86
|
+
.filter((run) => run.threadId === threadId)
|
|
87
|
+
.sort((a, b) => a.startedAt - b.startedAt)
|
|
88
|
+
return Promise.resolve(matching)
|
|
89
|
+
}
|
|
90
|
+
listReclaimable(opts: {
|
|
91
|
+
now: number
|
|
92
|
+
ttlMs: number
|
|
93
|
+
}): Promise<Array<RunRecord>> {
|
|
94
|
+
const cutoff = opts.now - opts.ttlMs
|
|
95
|
+
const matching = [...this.runs.values()].filter(
|
|
96
|
+
(run) =>
|
|
97
|
+
run.status === 'running' &&
|
|
98
|
+
run.detachedSince !== undefined &&
|
|
99
|
+
run.detachedSince <= cutoff,
|
|
100
|
+
)
|
|
101
|
+
return Promise.resolve(matching)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
class MemoryGenerationRunStore implements GenerationRunStore {
|
|
106
|
+
private readonly generationRuns = new Map<string, GenerationRunRecord>()
|
|
107
|
+
createOrResume(
|
|
108
|
+
input: Pick<
|
|
109
|
+
GenerationRunRecord,
|
|
110
|
+
'runId' | 'threadId' | 'activity' | 'provider' | 'model' | 'startedAt'
|
|
111
|
+
> & { status?: GenerationRunRecord['status'] },
|
|
112
|
+
): Promise<GenerationRunRecord> {
|
|
113
|
+
const existing = this.generationRuns.get(input.runId)
|
|
114
|
+
if (existing) return Promise.resolve(existing)
|
|
115
|
+
const record: GenerationRunRecord = {
|
|
116
|
+
runId: input.runId,
|
|
117
|
+
threadId: input.threadId,
|
|
118
|
+
activity: input.activity,
|
|
119
|
+
provider: input.provider,
|
|
120
|
+
model: input.model,
|
|
121
|
+
status: input.status ?? 'running',
|
|
122
|
+
startedAt: input.startedAt,
|
|
123
|
+
}
|
|
124
|
+
this.generationRuns.set(record.runId, record)
|
|
125
|
+
return Promise.resolve(record)
|
|
126
|
+
}
|
|
127
|
+
update(
|
|
128
|
+
runId: string,
|
|
129
|
+
patch: Partial<
|
|
130
|
+
Pick<
|
|
131
|
+
GenerationRunRecord,
|
|
132
|
+
'status' | 'finishedAt' | 'error' | 'result' | 'artifacts' | 'usage'
|
|
133
|
+
>
|
|
134
|
+
>,
|
|
135
|
+
): Promise<void> {
|
|
136
|
+
const existing = this.generationRuns.get(runId)
|
|
137
|
+
if (existing) this.generationRuns.set(runId, { ...existing, ...patch })
|
|
138
|
+
return Promise.resolve()
|
|
139
|
+
}
|
|
140
|
+
get(runId: string): Promise<GenerationRunRecord | null> {
|
|
141
|
+
return Promise.resolve(this.generationRuns.get(runId) ?? null)
|
|
142
|
+
}
|
|
143
|
+
findLatestForThread(threadId: string): Promise<GenerationRunRecord | null> {
|
|
144
|
+
const linked = [...this.generationRuns.values()]
|
|
145
|
+
.filter((run) => run.threadId === threadId)
|
|
146
|
+
.sort((a, b) => b.startedAt - a.startedAt)
|
|
147
|
+
return Promise.resolve(linked[0] ?? null)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function byRequestedAt(a: InterruptRecord, b: InterruptRecord): number {
|
|
152
|
+
return a.requestedAt - b.requestedAt
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
class MemoryInterruptStore implements InterruptStore {
|
|
156
|
+
private readonly interrupts = new Map<string, InterruptRecord>()
|
|
157
|
+
create(
|
|
158
|
+
record: Omit<InterruptRecord, 'status' | 'resolvedAt'>,
|
|
159
|
+
): Promise<void> {
|
|
160
|
+
// Insert-if-absent (canonical semantics, matching the SQL backends'
|
|
161
|
+
// ON CONFLICT DO NOTHING): a duplicate id must never clobber an existing —
|
|
162
|
+
// possibly already resolved — interrupt back to pending.
|
|
163
|
+
if (!this.interrupts.has(record.interruptId)) {
|
|
164
|
+
this.interrupts.set(record.interruptId, { ...record, status: 'pending' })
|
|
165
|
+
}
|
|
166
|
+
return Promise.resolve()
|
|
167
|
+
}
|
|
168
|
+
resolve(interruptId: string, response?: unknown): Promise<void> {
|
|
169
|
+
const existing = this.interrupts.get(interruptId)
|
|
170
|
+
if (existing) {
|
|
171
|
+
this.interrupts.set(interruptId, {
|
|
172
|
+
...existing,
|
|
173
|
+
status: 'resolved',
|
|
174
|
+
resolvedAt: Date.now(),
|
|
175
|
+
response,
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
return Promise.resolve()
|
|
179
|
+
}
|
|
180
|
+
cancel(interruptId: string): Promise<void> {
|
|
181
|
+
const existing = this.interrupts.get(interruptId)
|
|
182
|
+
if (existing) {
|
|
183
|
+
this.interrupts.set(interruptId, {
|
|
184
|
+
...existing,
|
|
185
|
+
status: 'cancelled',
|
|
186
|
+
resolvedAt: Date.now(),
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
return Promise.resolve()
|
|
190
|
+
}
|
|
191
|
+
get(interruptId: string): Promise<InterruptRecord | null> {
|
|
192
|
+
return Promise.resolve(this.interrupts.get(interruptId) ?? null)
|
|
193
|
+
}
|
|
194
|
+
list(threadId: string): Promise<Array<InterruptRecord>> {
|
|
195
|
+
return Promise.resolve(
|
|
196
|
+
[...this.interrupts.values()]
|
|
197
|
+
.filter((interrupt) => interrupt.threadId === threadId)
|
|
198
|
+
.sort(byRequestedAt),
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
listPending(threadId: string): Promise<Array<InterruptRecord>> {
|
|
202
|
+
return Promise.resolve(
|
|
203
|
+
[...this.interrupts.values()]
|
|
204
|
+
.filter(
|
|
205
|
+
(interrupt) =>
|
|
206
|
+
interrupt.threadId === threadId && interrupt.status === 'pending',
|
|
207
|
+
)
|
|
208
|
+
.sort(byRequestedAt),
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
listByRun(runId: string): Promise<Array<InterruptRecord>> {
|
|
212
|
+
return Promise.resolve(
|
|
213
|
+
[...this.interrupts.values()]
|
|
214
|
+
.filter((interrupt) => interrupt.runId === runId)
|
|
215
|
+
.sort(byRequestedAt),
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
listPendingByRun(runId: string): Promise<Array<InterruptRecord>> {
|
|
219
|
+
return Promise.resolve(
|
|
220
|
+
[...this.interrupts.values()]
|
|
221
|
+
.filter(
|
|
222
|
+
(interrupt) =>
|
|
223
|
+
interrupt.runId === runId && interrupt.status === 'pending',
|
|
224
|
+
)
|
|
225
|
+
.sort(byRequestedAt),
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
class MemoryMetadataStore implements MetadataStore {
|
|
231
|
+
// Nested maps so composite identity is `(namespace, key)` without the
|
|
232
|
+
// `${namespace}:${key}` collision where `('a:b','c')` aliases `('a','b:c')`.
|
|
233
|
+
// (This parameter is an app-defined metadata namespace string — not the
|
|
234
|
+
// shared `Scope` identity type from `@tanstack/ai`.)
|
|
235
|
+
private readonly values = new Map<string, Map<string, unknown>>()
|
|
236
|
+
get(namespace: string, key: string): Promise<unknown | null> {
|
|
237
|
+
const bucket = this.values.get(namespace)
|
|
238
|
+
if (!bucket || !bucket.has(key)) return Promise.resolve(null)
|
|
239
|
+
return Promise.resolve(bucket.get(key))
|
|
240
|
+
}
|
|
241
|
+
set(namespace: string, key: string, value: unknown): Promise<void> {
|
|
242
|
+
let bucket = this.values.get(namespace)
|
|
243
|
+
if (!bucket) {
|
|
244
|
+
bucket = new Map()
|
|
245
|
+
this.values.set(namespace, bucket)
|
|
246
|
+
}
|
|
247
|
+
bucket.set(key, value)
|
|
248
|
+
return Promise.resolve()
|
|
249
|
+
}
|
|
250
|
+
delete(namespace: string, key: string): Promise<void> {
|
|
251
|
+
const bucket = this.values.get(namespace)
|
|
252
|
+
if (!bucket) return Promise.resolve()
|
|
253
|
+
bucket.delete(key)
|
|
254
|
+
if (bucket.size === 0) this.values.delete(namespace)
|
|
255
|
+
return Promise.resolve()
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
class MemoryArtifactStore implements ArtifactStore {
|
|
260
|
+
private readonly artifacts = new Map<string, ArtifactRecord>()
|
|
261
|
+
save(record: ArtifactRecord): Promise<void> {
|
|
262
|
+
this.artifacts.set(record.artifactId, { ...record })
|
|
263
|
+
return Promise.resolve()
|
|
264
|
+
}
|
|
265
|
+
get(artifactId: string): Promise<ArtifactRecord | null> {
|
|
266
|
+
return Promise.resolve(this.artifacts.get(artifactId) ?? null)
|
|
267
|
+
}
|
|
268
|
+
list(runId: string): Promise<Array<ArtifactRecord>> {
|
|
269
|
+
return Promise.resolve(
|
|
270
|
+
[...this.artifacts.values()].filter((a) => a.runId === runId),
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
delete(artifactId: string): Promise<void> {
|
|
274
|
+
this.artifacts.delete(artifactId)
|
|
275
|
+
return Promise.resolve()
|
|
276
|
+
}
|
|
277
|
+
deleteForRun(runId: string): Promise<void> {
|
|
278
|
+
for (const artifact of this.artifacts.values()) {
|
|
279
|
+
if (artifact.runId === runId) this.artifacts.delete(artifact.artifactId)
|
|
280
|
+
}
|
|
281
|
+
return Promise.resolve()
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
interface MemoryBlobEntry {
|
|
286
|
+
record: BlobRecord
|
|
287
|
+
bytes: Uint8Array
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const textEncoder = new TextEncoder()
|
|
291
|
+
const textDecoder = new TextDecoder()
|
|
292
|
+
|
|
293
|
+
function copyBytes(bytes: Uint8Array): Uint8Array {
|
|
294
|
+
return new Uint8Array(bytes)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function bytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
298
|
+
const buffer = new ArrayBuffer(bytes.byteLength)
|
|
299
|
+
new Uint8Array(buffer).set(bytes)
|
|
300
|
+
return buffer
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function bytesFromStream(
|
|
304
|
+
stream: ReadableStream<Uint8Array>,
|
|
305
|
+
): Promise<Uint8Array> {
|
|
306
|
+
const reader = stream.getReader()
|
|
307
|
+
const chunks: Array<Uint8Array> = []
|
|
308
|
+
let total = 0
|
|
309
|
+
try {
|
|
310
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
311
|
+
while (true) {
|
|
312
|
+
const { done, value } = await reader.read()
|
|
313
|
+
if (done) break
|
|
314
|
+
chunks.push(copyBytes(value))
|
|
315
|
+
total += value.byteLength
|
|
316
|
+
}
|
|
317
|
+
} finally {
|
|
318
|
+
reader.releaseLock()
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const bytes = new Uint8Array(total)
|
|
322
|
+
let offset = 0
|
|
323
|
+
for (const chunk of chunks) {
|
|
324
|
+
bytes.set(chunk, offset)
|
|
325
|
+
offset += chunk.byteLength
|
|
326
|
+
}
|
|
327
|
+
return bytes
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function bytesFromBlobBody(body: BlobBody): Promise<Uint8Array> {
|
|
331
|
+
if (typeof body === 'string') {
|
|
332
|
+
return textEncoder.encode(body)
|
|
333
|
+
}
|
|
334
|
+
if (body instanceof ArrayBuffer) {
|
|
335
|
+
return new Uint8Array(body.slice(0))
|
|
336
|
+
}
|
|
337
|
+
if (ArrayBuffer.isView(body)) {
|
|
338
|
+
return copyBytes(
|
|
339
|
+
new Uint8Array(body.buffer, body.byteOffset, body.byteLength),
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
if (typeof Blob !== 'undefined' && body instanceof Blob) {
|
|
343
|
+
return new Uint8Array(await body.arrayBuffer())
|
|
344
|
+
}
|
|
345
|
+
if (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) {
|
|
346
|
+
return bytesFromStream(body)
|
|
347
|
+
}
|
|
348
|
+
throw new TypeError('Unsupported blob body.')
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function blobRecordSnapshot(record: BlobRecord): BlobRecord {
|
|
352
|
+
return {
|
|
353
|
+
...record,
|
|
354
|
+
...(record.customMetadata
|
|
355
|
+
? { customMetadata: { ...record.customMetadata } }
|
|
356
|
+
: {}),
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function blobObject(
|
|
361
|
+
record: BlobRecord,
|
|
362
|
+
bytes: Uint8Array,
|
|
363
|
+
range?: BlobRange,
|
|
364
|
+
): BlobObject {
|
|
365
|
+
// `size` keeps reporting the whole object; only the bytes narrow.
|
|
366
|
+
const served = range
|
|
367
|
+
? resolveBlobRange(bytes.byteLength, range)
|
|
368
|
+
: { offset: 0, length: bytes.byteLength }
|
|
369
|
+
const copied = copyBytes(
|
|
370
|
+
bytes.subarray(served.offset, served.offset + served.length),
|
|
371
|
+
)
|
|
372
|
+
return {
|
|
373
|
+
...blobRecordSnapshot(record),
|
|
374
|
+
...(range ? { range: served } : {}),
|
|
375
|
+
body: new ReadableStream<Uint8Array>({
|
|
376
|
+
start(controller) {
|
|
377
|
+
controller.enqueue(copyBytes(copied))
|
|
378
|
+
controller.close()
|
|
379
|
+
},
|
|
380
|
+
}),
|
|
381
|
+
arrayBuffer: () => Promise.resolve(bytesToArrayBuffer(copied)),
|
|
382
|
+
text: () => Promise.resolve(textDecoder.decode(copied)),
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
class MemoryBlobStore implements BlobStore {
|
|
387
|
+
private readonly blobs = new Map<string, MemoryBlobEntry>()
|
|
388
|
+
private nextEtag = 1
|
|
389
|
+
|
|
390
|
+
async put(
|
|
391
|
+
key: string,
|
|
392
|
+
body: BlobBody,
|
|
393
|
+
options?: BlobPutOptions,
|
|
394
|
+
): Promise<BlobRecord> {
|
|
395
|
+
const bytes = await bytesFromBlobBody(body)
|
|
396
|
+
const existing = this.blobs.get(key)
|
|
397
|
+
const now = Date.now()
|
|
398
|
+
const record: BlobRecord = {
|
|
399
|
+
key,
|
|
400
|
+
size: bytes.byteLength,
|
|
401
|
+
etag: String(this.nextEtag++),
|
|
402
|
+
contentType:
|
|
403
|
+
options?.contentType ??
|
|
404
|
+
(typeof Blob !== 'undefined' && body instanceof Blob
|
|
405
|
+
? body.type || undefined
|
|
406
|
+
: undefined),
|
|
407
|
+
customMetadata: options?.customMetadata
|
|
408
|
+
? { ...options.customMetadata }
|
|
409
|
+
: undefined,
|
|
410
|
+
createdAt: existing?.record.createdAt ?? now,
|
|
411
|
+
updatedAt: now,
|
|
412
|
+
}
|
|
413
|
+
this.blobs.set(key, { record, bytes: copyBytes(bytes) })
|
|
414
|
+
return blobRecordSnapshot(record)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
get(key: string, options?: BlobGetOptions): Promise<BlobObject | null> {
|
|
418
|
+
const entry = this.blobs.get(key)
|
|
419
|
+
return Promise.resolve(
|
|
420
|
+
entry ? blobObject(entry.record, entry.bytes, options?.range) : null,
|
|
421
|
+
)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
head(key: string): Promise<BlobRecord | null> {
|
|
425
|
+
const entry = this.blobs.get(key)
|
|
426
|
+
return Promise.resolve(entry ? blobRecordSnapshot(entry.record) : null)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
delete(key: string): Promise<void> {
|
|
430
|
+
this.blobs.delete(key)
|
|
431
|
+
return Promise.resolve()
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
list(options?: BlobListOptions): Promise<{
|
|
435
|
+
objects: Array<BlobRecord>
|
|
436
|
+
cursor?: string
|
|
437
|
+
truncated?: boolean
|
|
438
|
+
}> {
|
|
439
|
+
const limit = options?.limit
|
|
440
|
+
if (limit === 0) {
|
|
441
|
+
return Promise.resolve({ objects: [], truncated: false })
|
|
442
|
+
}
|
|
443
|
+
const keys = [...this.blobs.keys()]
|
|
444
|
+
.filter((key) => key.startsWith(options?.prefix ?? ''))
|
|
445
|
+
.filter((key) => options?.cursor === undefined || key > options.cursor)
|
|
446
|
+
.sort()
|
|
447
|
+
const pageKeys = limit === undefined ? keys : keys.slice(0, limit)
|
|
448
|
+
const objects = pageKeys.map((key) => {
|
|
449
|
+
const blob = this.blobs.get(key)
|
|
450
|
+
if (blob === undefined) {
|
|
451
|
+
throw new Error(`Missing blob for listed key: ${key}`)
|
|
452
|
+
}
|
|
453
|
+
return blobRecordSnapshot(blob.record)
|
|
454
|
+
})
|
|
455
|
+
const truncated = limit !== undefined && keys.length > limit
|
|
456
|
+
return Promise.resolve({
|
|
457
|
+
objects,
|
|
458
|
+
...(truncated ? { cursor: pageKeys.at(-1), truncated } : {}),
|
|
459
|
+
})
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
interface MemoryPersistenceStores {
|
|
464
|
+
messages: MessageStore
|
|
465
|
+
runs: RunStore
|
|
466
|
+
generationRuns: GenerationRunStore
|
|
467
|
+
interrupts: InterruptStore
|
|
468
|
+
metadata: MetadataStore
|
|
469
|
+
artifacts: ArtifactStore
|
|
470
|
+
blobs: BlobStore
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* In-process reference backend for the full state + generation store set.
|
|
475
|
+
*
|
|
476
|
+
* Returns messages + runs + generationRuns + interrupts + metadata + artifacts
|
|
477
|
+
* + blobs. Locks are not included — use `InMemoryLockStore` + `withLocks` from
|
|
478
|
+
* `@tanstack/ai` when a test or single-process app needs coordination.
|
|
479
|
+
*/
|
|
480
|
+
export function memoryPersistence() {
|
|
481
|
+
const stores: MemoryPersistenceStores = {
|
|
482
|
+
messages: new MemoryMessageStore(),
|
|
483
|
+
runs: new MemoryRunStore(),
|
|
484
|
+
generationRuns: new MemoryGenerationRunStore(),
|
|
485
|
+
interrupts: new MemoryInterruptStore(),
|
|
486
|
+
metadata: new MemoryMetadataStore(),
|
|
487
|
+
artifacts: new MemoryArtifactStore(),
|
|
488
|
+
blobs: new MemoryBlobStore(),
|
|
489
|
+
}
|
|
490
|
+
return defineAIPersistence({ stores })
|
|
491
|
+
}
|