You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
Copy file name to clipboardExpand all lines: docs/ai-chat/patterns/database-persistence.mdx
+40-9Lines changed: 40 additions & 9 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -58,6 +58,28 @@ If you skip preload, do the equivalent in **`onChatStart`** when **`preloaded`**
58
58
59
59
**`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.
60
60
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.
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).
0 commit comments