@tanstack/ai-persistence 0.5.7 → 0.6.2

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.
@@ -1,7 +1,14 @@
1
1
  import { modelMessagesToUIMessages } from '@tanstack/ai'
2
- import type { UIMessage } from '@tanstack/ai'
2
+ import type { ModelMessage, UIMessage } from '@tanstack/ai'
3
3
  import { validateReconstructChatStores } from './types'
4
- import type { AIPersistence, ChatTranscriptStores } from './types'
4
+ import type {
5
+ AIPersistence,
6
+ ChatTranscriptStores,
7
+ MessagePage,
8
+ MessageStore,
9
+ } from './types'
10
+
11
+ const MAX_PAGE_SIZE = 500
5
12
 
6
13
  /**
7
14
  * The JSON body `reconstructChat` returns and a server-authoritative client
@@ -15,6 +22,9 @@ import type { AIPersistence, ChatTranscriptStores } from './types'
15
22
  * approvals, client-tool/generic waits) and the run they paused, or `null` —
16
23
  * so a reload (or another device) re-prompts the approval from the SERVER, not
17
24
  * from client storage. Resolved via `stores.interrupts.listPending`.
25
+ * `page` is set only when the GET included a valid `limit`. `truncated` is true
26
+ * when older UI messages exist. `cursor` is the opaque `before` token for the
27
+ * next older window.
18
28
  */
19
29
  export interface ReconstructedChat {
20
30
  messages: Array<UIMessage>
@@ -23,6 +33,7 @@ export interface ReconstructedChat {
23
33
  runId: string
24
34
  pending: Array<Record<string, unknown>>
25
35
  } | null
36
+ page?: { truncated: false } | { truncated: true; cursor: string }
26
37
  }
27
38
 
28
39
  export interface ReconstructChatOptions {
@@ -63,6 +74,11 @@ export interface ReconstructChatOptions {
63
74
  * a reload re-prompts the decision from the server. Resolved via the optional
64
75
  * `stores.interrupts.listPending`; `null` when that store is absent.
65
76
  *
77
+ * Paging is opt-in. A valid `?limit=` (positive integer, capped at 500) returns
78
+ * the newest window of UI messages plus `page`. `?before=` walks to an older
79
+ * window. Invalid `limit` (`0`, negative, NaN) is ignored and the full
80
+ * transcript is returned. `activeRun` and `interrupts` are never paged.
81
+ *
66
82
  * Requires `stores.messages`. Returns an empty transcript with no active run
67
83
  * and no interrupts when the thread id is missing or the thread is unknown, so
68
84
  * the caller never has to special-case a first load.
@@ -94,8 +110,11 @@ export async function reconstructChat(
94
110
  throw new Error('reconstructChat requires stores.messages.')
95
111
  }
96
112
 
113
+ const requestUrl = new URL(request.url)
97
114
  const param = options?.param ?? 'threadId'
98
- const threadId = new URL(request.url).searchParams.get(param) ?? ''
115
+ const threadId = requestUrl.searchParams.get(param) ?? ''
116
+ const pageSize = parsePageSize(requestUrl.searchParams.get('limit'))
117
+ const before = parseBefore(requestUrl.searchParams.get('before'))
99
118
 
100
119
  if (threadId && options?.authorize) {
101
120
  const decision = await options.authorize(threadId, request)
@@ -122,7 +141,15 @@ export async function reconstructChat(
122
141
  const active = threadId
123
142
  ? await persistence.stores.runs?.findActiveRun(threadId)
124
143
  : null
125
- const stored = threadId ? await messageStore.loadThread(threadId) : []
144
+ const stored =
145
+ threadId === ''
146
+ ? []
147
+ : pageSize === undefined
148
+ ? await messageStore.loadThread(threadId)
149
+ : await messageStore.loadThread(threadId, {
150
+ limit: pageSize + 1,
151
+ ...(before === undefined ? {} : { before }),
152
+ })
126
153
  // Pending interrupts for the thread, so a reload re-prompts the approval from
127
154
  // the server. Each stored `payload` is the full interrupt descriptor the
128
155
  // client hydrates; they share the run they paused.
@@ -130,8 +157,22 @@ export async function reconstructChat(
130
157
  ? ((await persistence.stores.interrupts?.listPending(threadId)) ?? [])
131
158
  : []
132
159
  const firstPending = pending[0]
160
+ const isPaging = pageSize !== undefined && threadId !== ''
161
+ const transcript = !isPaging
162
+ ? {
163
+ messages: modelMessagesToUIMessages(threadMessages(stored)),
164
+ }
165
+ : Array.isArray(stored)
166
+ ? await windowFromArray({
167
+ stored,
168
+ messageStore,
169
+ threadId,
170
+ pageSize,
171
+ before,
172
+ })
173
+ : windowFromMessagePage(stored, pageSize)
133
174
  const body: ReconstructedChat = {
134
- messages: modelMessagesToUIMessages(stored),
175
+ messages: transcript.messages,
135
176
  activeRun: active ? { runId: active.runId } : null,
136
177
  interrupts: firstPending
137
178
  ? {
@@ -139,6 +180,7 @@ export async function reconstructChat(
139
180
  pending: pending.map((record) => record.payload),
140
181
  }
141
182
  : null,
183
+ ...('page' in transcript ? { page: transcript.page } : {}),
142
184
  }
143
185
  return new Response(JSON.stringify(body), {
144
186
  headers: {
@@ -147,3 +189,92 @@ export async function reconstructChat(
147
189
  },
148
190
  })
149
191
  }
192
+
193
+ function parsePageSize(raw: string | null) {
194
+ if (raw == null) return
195
+ const pageSize = Number(raw)
196
+ const isValidPageSize = Number.isInteger(pageSize) && pageSize > 0
197
+ if (!isValidPageSize) return
198
+ return Math.min(pageSize, MAX_PAGE_SIZE)
199
+ }
200
+
201
+ function parseBefore(raw: string | null) {
202
+ if (raw == null || raw === '') return
203
+ return raw
204
+ }
205
+
206
+ function threadMessages(
207
+ loaded: Array<ModelMessage> | MessagePage,
208
+ ): Array<ModelMessage> {
209
+ return Array.isArray(loaded) ? loaded : loaded.messages
210
+ }
211
+
212
+ function completePage() {
213
+ return { truncated: false as const }
214
+ }
215
+
216
+ function truncatedPage(cursor: string) {
217
+ return { truncated: true as const, cursor }
218
+ }
219
+
220
+ function pageFromCursor(cursor: string | undefined) {
221
+ if (cursor === undefined || cursor === '') {
222
+ return completePage()
223
+ }
224
+ return truncatedPage(cursor)
225
+ }
226
+
227
+ function newestUiWindow(messages: Array<UIMessage>, pageSize: number) {
228
+ const truncated = messages.length > pageSize
229
+ if (!truncated) {
230
+ return { messages, page: completePage() }
231
+ }
232
+ const uiWindow = messages.slice(messages.length - pageSize)
233
+ return {
234
+ messages: uiWindow,
235
+ page: pageFromCursor(uiWindow[0]?.id),
236
+ }
237
+ }
238
+
239
+ function uiBeforeCursor(messages: Array<UIMessage>, cursor: string) {
240
+ const cut = messages.findIndex((message) => message.id === cursor)
241
+ if (cut === -1) return
242
+ return messages.slice(0, cut)
243
+ }
244
+
245
+ function windowFromMessagePage(page: MessagePage, pageSize: number) {
246
+ const ui = modelMessagesToUIMessages(page.messages)
247
+ if (ui.length > pageSize) {
248
+ // Extra slice uses a library-minted cursor. Keeping the adapter cursor
249
+ // after dropping the oldest row would skip that row on the next GET.
250
+ return newestUiWindow(ui, pageSize)
251
+ }
252
+ if (page.truncated) {
253
+ return {
254
+ messages: ui,
255
+ page: pageFromCursor(page.cursor),
256
+ }
257
+ }
258
+ return { messages: ui, page: completePage() }
259
+ }
260
+
261
+ async function windowFromArray(input: {
262
+ stored: Array<ModelMessage>
263
+ messageStore: MessageStore
264
+ threadId: string
265
+ pageSize: number
266
+ before: string | undefined
267
+ }) {
268
+ const { stored, messageStore, threadId, pageSize, before } = input
269
+ if (before === undefined) {
270
+ return newestUiWindow(modelMessagesToUIMessages(stored), pageSize)
271
+ }
272
+ // Array adapters own no cursor. Apply `before` to the full transcript so an
273
+ // adapter that ignored the hint cannot return the same newest page forever.
274
+ const full = threadMessages(await messageStore.loadThread(threadId))
275
+ const older = uiBeforeCursor(modelMessagesToUIMessages(full), before)
276
+ if (older === undefined) {
277
+ return { messages: [], page: truncatedPage(before) }
278
+ }
279
+ return newestUiWindow(older, pageSize)
280
+ }
@@ -171,6 +171,8 @@ export function runPersistenceConformance(
171
171
  }
172
172
 
173
173
  describe('messages', () => {
174
+ // One-argument loadThread is the full-thread contract. Paging
175
+ // (`limit` / `before`) is an optional hint; this suite does not require it.
174
176
  it('round-trips a thread and returns [] for unknown threads', async (ctx) => {
175
177
  const store = resolveStore('messages')
176
178
  if (!store) return ctx.skip('store not provided')
@@ -181,7 +183,9 @@ export function runPersistenceConformance(
181
183
  { role: 'user', content: 'hi' },
182
184
  { role: 'assistant', content: 'hello' },
183
185
  ])
184
- expect(await store.loadThread('thread-msg')).toEqual([
186
+ const loaded = await store.loadThread('thread-msg')
187
+ expect(Array.isArray(loaded)).toBe(true)
188
+ expect(loaded).toEqual([
185
189
  { role: 'user', content: 'hi' },
186
190
  { role: 'assistant', content: 'hello' },
187
191
  ])
package/src/types.ts CHANGED
@@ -53,6 +53,29 @@ export type { MetadataStore, Scope }
53
53
  // **ISO-8601 strings**. The middleware performs the number→ISO conversion at
54
54
  // the boundary; do not mix the two on a single field.
55
55
 
56
+ /**
57
+ * One page of a thread from {@link MessageStore.loadThread} when the caller
58
+ * passed a paging hint.
59
+ *
60
+ * Middleware omits the hint and always gets a full `Array<ModelMessage>`,
61
+ * never this shape.
62
+ *
63
+ * `truncated: true` requires `cursor`. Without a cursor the client cannot
64
+ * request the next older window, so `reconstructChat` treats that page as
65
+ * complete.
66
+ */
67
+ export type MessagePage =
68
+ | {
69
+ messages: Array<ModelMessage>
70
+ truncated: false
71
+ cursor?: never
72
+ }
73
+ | {
74
+ messages: Array<ModelMessage>
75
+ truncated: true
76
+ cursor: string
77
+ }
78
+
56
79
  /**
57
80
  * Durable store for a thread's full message transcript.
58
81
  *
@@ -70,13 +93,27 @@ export type { MetadataStore, Scope }
70
93
  */
71
94
  export interface MessageStore {
72
95
  /**
73
- * Return the full stored transcript for `threadId` ({@link Scope.threadId}),
96
+ * Return the stored transcript for `threadId` ({@link Scope.threadId}),
74
97
  * in insertion order.
75
98
  *
99
+ * Call with only `threadId` (middleware, `onStart`, `onFinish`) and this
100
+ * MUST return the full transcript as an `Array<ModelMessage>`. Never a
101
+ * {@link MessagePage}.
102
+ *
103
+ * `options.limit` and `options.before` are an optional paging hint for
104
+ * hydrate. Adapters may ignore them and still return the full array. An
105
+ * adapter that pages returns a {@link MessagePage}.
106
+ *
76
107
  * INVARIANT: returns an empty array (never `null`/`undefined`) for a thread
77
108
  * that was never saved. Callers treat `[]` as "no history".
78
109
  */
79
- loadThread: (threadId: string) => Promise<Array<ModelMessage>>
110
+ loadThread: {
111
+ (threadId: string): Promise<Array<ModelMessage>>
112
+ (
113
+ threadId: string,
114
+ options: { limit?: number; before?: string },
115
+ ): Promise<Array<ModelMessage> | MessagePage>
116
+ }
80
117
  /**
81
118
  * Overwrite the stored transcript for `threadId` with `messages`.
82
119
  *