@tanstack/ai-persistence 0.5.6 → 0.6.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/src/middleware.ts CHANGED
@@ -47,6 +47,7 @@ import type {
47
47
  GenerationMiddleware,
48
48
  GenerationMiddlewareContext,
49
49
  Interrupt,
50
+ ModelMessage,
50
51
  PendingInterruptResumeRecord,
51
52
  PersistedArtifactActivity,
52
53
  PersistedArtifactRef,
@@ -66,6 +67,7 @@ import type {
66
67
  ChatTranscriptStores,
67
68
  InterruptCommitEntry,
68
69
  InterruptRecord,
70
+ MessagePage,
69
71
  RunStore,
70
72
  } from './types'
71
73
  import { artifactBlobKey } from './retrieve'
@@ -1907,26 +1909,78 @@ function detachableRun(ctx: ChatMiddlewareContext): boolean {
1907
1909
  // Chat middleware
1908
1910
  // ---------------------------------------------------------------------------
1909
1911
 
1910
- /**
1911
- * Chat-only **state** persistence middleware. Provides durable transcript,
1912
- * run records, and interrupts for `chat()`. Does **not** provide locks —
1913
- * use `withLocks` from `@tanstack/ai` for multi-instance coordination.
1914
- *
1915
- * This middleware never mutates the chunk stream; delivery durability
1916
- * (replaying a disconnected/reloaded stream) is a separate transport-layer
1917
- * concern (see the resumable-streams docs).
1918
- *
1919
- * Requires `stores.messages`. When `stores.interrupts` is present,
1920
- * `stores.runs` is also required.
1921
- *
1922
- * ⚠️ AUTHORITATIVE-HISTORY CONTRACT: when a request carries a non-empty
1923
- * `messages` array it is treated as the FULL conversation history and, on
1924
- * finish, **overwrites** the entire stored thread. Post only the complete
1925
- * transcript, never a delta — sending just the newest message(s) will replace
1926
- * (and thereby destroy) the stored thread. To continue a stored thread without
1927
- * resending history, pass an empty `messages` array and the stored transcript
1928
- * is loaded and used.
1929
- */
1912
+ function threadMessages(
1913
+ loaded: Array<ModelMessage> | MessagePage,
1914
+ ): Array<ModelMessage> {
1915
+ return Array.isArray(loaded) ? loaded : loaded.messages
1916
+ }
1917
+
1918
+ // Empty incoming keeps stored. Non-empty: the last incoming id that already
1919
+ // exists in stored is a cutoff (reload drops the old assistant after that
1920
+ // user). Same id is replaced in place. New ids and messages with no id are
1921
+ // appended.
1922
+ function mergeStoredMessages(
1923
+ stored: ReadonlyArray<ModelMessage>,
1924
+ incoming: ReadonlyArray<ModelMessage>,
1925
+ ) {
1926
+ if (incoming.length === 0) {
1927
+ return stored.slice()
1928
+ }
1929
+
1930
+ let cutoff = stored.length
1931
+ for (let index = incoming.length - 1; index >= 0; index--) {
1932
+ const id = incoming[index]?.id
1933
+ if (id === undefined) continue
1934
+ const storedIndex = stored.findIndex((message) => message.id === id)
1935
+ if (storedIndex >= 0) {
1936
+ cutoff = storedIndex + 1
1937
+ break
1938
+ }
1939
+ }
1940
+ const prefix = stored.slice(0, cutoff)
1941
+
1942
+ const incomingById = new Map<string, ModelMessage>()
1943
+ for (const message of incoming) {
1944
+ const id = message.id
1945
+ if (id) incomingById.set(id, message)
1946
+ }
1947
+
1948
+ const storedIds = new Set<string>()
1949
+ const merged: Array<ModelMessage> = []
1950
+ for (const message of prefix) {
1951
+ const id = message.id
1952
+ if (id) {
1953
+ storedIds.add(id)
1954
+ merged.push(incomingById.get(id) ?? message)
1955
+ continue
1956
+ }
1957
+ merged.push(message)
1958
+ }
1959
+
1960
+ for (let index = 0; index < incoming.length; index++) {
1961
+ const message = incoming[index]
1962
+ if (!message) continue
1963
+ const id = message.id
1964
+ if (id && storedIds.has(id)) continue
1965
+ // Attach/reload can post the stored transcript again with no ids. Keep the
1966
+ // prefix row instead of appending a second copy of the same turn.
1967
+ if (!id) {
1968
+ const existing = merged[index]
1969
+ if (
1970
+ existing &&
1971
+ existing.id === undefined &&
1972
+ existing.role === message.role &&
1973
+ existing.content === message.content
1974
+ ) {
1975
+ continue
1976
+ }
1977
+ }
1978
+ merged.push(message)
1979
+ }
1980
+
1981
+ return merged
1982
+ }
1983
+
1930
1984
  export interface WithPersistenceOptions {
1931
1985
  /**
1932
1986
  * Also persist a throttled snapshot of the in-progress assistant reply while
@@ -1945,6 +1999,24 @@ export interface WithPersistenceOptions {
1945
1999
  }
1946
2000
 
1947
2001
  /**
2002
+ * Chat-only **state** persistence middleware. Provides durable transcript,
2003
+ * run records, and interrupts for `chat()`. Does **not** provide locks —
2004
+ * use `withLocks` from `@tanstack/ai` for multi-instance coordination.
2005
+ *
2006
+ * This middleware never mutates the chunk stream; delivery durability
2007
+ * (replaying a disconnected/reloaded stream) is a separate transport-layer
2008
+ * concern (see the resumable-streams docs).
2009
+ *
2010
+ * Requires `stores.messages`. When `stores.interrupts` is present,
2011
+ * `stores.runs` is also required.
2012
+ *
2013
+ * Incoming `messages` merge into the stored thread by id. An empty list loads
2014
+ * the stored thread. The last incoming id that already exists in stored is a
2015
+ * cutoff; stored messages after it are dropped (reload). If no incoming id is
2016
+ * in stored, every stored message stays. Same id: incoming wins. New ids and
2017
+ * messages with no id are appended. `saveThread` still replaces the thread
2018
+ * with that merged list.
2019
+ *
1948
2020
  * @param persistence - Must satisfy {@link ChatTranscriptStores} (messages
1949
2021
  * required). Known-absent `messages` or `interrupts` without `runs` fail at
1950
2022
  * compile time; fully dynamic bags are checked at runtime.
@@ -2019,11 +2091,12 @@ export function withPersistence<TStores extends ChatTranscriptStores>(
2019
2091
  // it behaves exactly as before. See `PendingTurnCapability`.
2020
2092
  providePendingTurn(ctx, {
2021
2093
  snapshot: async () => {
2022
- const stored = await messageStore.loadThread(ctx.threadId)
2023
- // The SAME rule `onConfig` applies when it merges. Kept here, in the
2024
- // owner, because `saveThread` REPLACES the thread: a caller that stored
2025
- // only the newly-sent list would delete the history.
2026
- const list = ctx.messages.length > 0 ? [...ctx.messages] : stored
2094
+ const stored = threadMessages(
2095
+ await messageStore.loadThread(ctx.threadId),
2096
+ )
2097
+ // Same merge as onConfig. saveThread replaces the thread, so a short
2098
+ // incoming list must not drop stored extras.
2099
+ const list = mergeStoredMessages(stored, ctx.messages)
2027
2100
  await messageStore.saveThread(ctx.threadId, list)
2028
2101
  },
2029
2102
  })
@@ -2088,8 +2161,10 @@ export function withPersistence<TStores extends ChatTranscriptStores>(
2088
2161
  if (state && storedUsage) state.usage = storedUsage
2089
2162
  if (!state?.merged) {
2090
2163
  if (state) state.merged = true
2091
- const stored = await messageStore.loadThread(ctx.threadId)
2092
- patch.messages = config.messages.length > 0 ? config.messages : stored
2164
+ const stored = threadMessages(
2165
+ await messageStore.loadThread(ctx.threadId),
2166
+ )
2167
+ patch.messages = mergeStoredMessages(stored, config.messages)
2093
2168
  }
2094
2169
 
2095
2170
  return Object.keys(patch).length > 0 ? patch : undefined
@@ -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
  *