Skip to content

Commit eb9223f

Browse files
committed
docs(ai-chat): warn against non-atomic onTurnComplete persistence
The page-load reads Chat.messages and ChatSession.lastEventId in parallel. A non-atomic onTurnComplete that writes them as two separate awaits has a narrow race window where messages are post-write but lastEventId is still pre-write — the transport then replays this turn's chunks on resume and duplicates the assistant render. Add a Warning callout in the persistence pattern doc with the ✅ atomic and ❌ non-atomic shapes, and update both code examples (basic + hydrateMessages variant) to use prisma.$transaction.
1 parent 5f48914 commit eb9223f

1 file changed

Lines changed: 40 additions & 9 deletions

File tree

docs/ai-chat/patterns/database-persistence.mdx

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,28 @@ If you skip preload, do the equivalent in **`onChatStart`** when **`preloaded`**
5858

5959
**`lastEventId`** lets the frontend [resume](/ai-chat/frontend) without replaying SSE events it already applied. Treat it as part of session state, not optional polish, if you care about duplicate chunks after refresh.
6060

61+
<Warning>
62+
**Write the messages and `lastEventId` in a single transaction.** Both values are read in parallel on the next page load (one fetches the conversation, the other fetches the session). If a refresh races between the two writes, the page can see the assistant message persisted (full history) but a stale `lastEventId` from the previous turn. The transport then resumes from that stale cursor and replays this turn's chunks on top of the already-persisted assistant message, producing a duplicated render.
63+
64+
```ts
65+
// ✅ Atomic — refresh on the next page load reads both writes consistently.
66+
await db.$transaction([
67+
db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } }),
68+
db.chatSession.upsert({
69+
where: { id: chatId },
70+
create: { id: chatId, publicAccessToken: chatAccessToken, lastEventId },
71+
update: { publicAccessToken: chatAccessToken, lastEventId },
72+
}),
73+
]);
74+
75+
// ❌ Two awaits — narrow race window where messages are post-write but
76+
// lastEventId is still pre-write. A page refresh that lands here will
77+
// duplicate the assistant message on resume.
78+
await db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } });
79+
await db.chatSession.upsert({ /* ... */ });
80+
```
81+
</Warning>
82+
6183
## Token renewal (app server)
6284

6385
The persisted PAT has a TTL (see **`chatAccessTokenTTL`** on **`chat.agent`**, default 1h). When the transport gets a **401** on a session-PAT-authed request, it calls your **`accessToken`** callback to mint a fresh PAT — no DB lookup required, since the session is keyed on `chatId` (which the transport already has).
@@ -110,12 +132,12 @@ chat.agent({
110132
},
111133

112134
onTurnComplete: async ({ chatId, uiMessages, chatAccessToken, lastEventId }) => {
113-
await saveConversationMessages(chatId, uiMessages);
114-
await upsertSession({
115-
chatId,
116-
publicAccessToken: chatAccessToken,
117-
lastEventId,
118-
});
135+
// Atomic: messages + lastEventId must be readable consistently on resume.
136+
// See the warning above for why a non-atomic write causes duplicate renders.
137+
await db.$transaction([
138+
saveConversationMessagesQuery(chatId, uiMessages),
139+
upsertSessionQuery({ chatId, publicAccessToken: chatAccessToken, lastEventId }),
140+
]);
119141
},
120142

121143
run: async ({ messages, signal }) => {
@@ -144,9 +166,18 @@ export const myChat = chat.agent({
144166

145167
return stored;
146168
},
147-
onTurnComplete: async ({ chatId, uiMessages }) => {
148-
// Persist the response
149-
await db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } });
169+
onTurnComplete: async ({ chatId, uiMessages, chatAccessToken, lastEventId }) => {
170+
// Persist the response and refresh session state atomically — see the
171+
// warning in the previous section for why these two writes have to be
172+
// in the same transaction.
173+
await db.$transaction([
174+
db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } }),
175+
db.chatSession.upsert({
176+
where: { id: chatId },
177+
create: { id: chatId, publicAccessToken: chatAccessToken, lastEventId },
178+
update: { publicAccessToken: chatAccessToken, lastEventId },
179+
}),
180+
]);
150181
},
151182
run: async ({ messages, signal }) => {
152183
return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal });

0 commit comments

Comments
 (0)