chat: durable Postgres event queue + cursor replay on reconnect (Phase 2+3)

Completes the turn/session decoupling so nothing is lost across disconnects.

Phase 2 (durability):
- New chat_session_events table (global monotonic id = cursor) + queries
  appendChatEvent / getChatEventsSince / pruneChatEventsOlderThan.
- Every durable outbound ServerMessage now goes through emitToSession: appended
  to the queue (even while the client is disconnected) and delivered live with
  its seq. Streaming deltas stay ephemeral (live-only, never persisted).

Phase 3 (resilient transport):
- New 'resume-cursor' client message → handleResumeCursor re-binds the socket to
  the (still-live) session (cancels idle-GC via attachWs) and replays every event
  since the client's cursor.
- useChatWebSocket already auto-reconnects; added an onOpen hook. useChat tracks
  the max seq and, on every (re)connect with an established session, sends
  resume-cursor — so a dropped connection self-heals with no manual navigate
  away/back, and background task notifications that landed while offline replay.

Verified end-to-end: disconnect after a turn's result but before a background
task finishes, reconnect with the cursor → the missed task:notification is
replayed from Postgres, no duplicates.

Note: the DB is managed via drizzle push/direct DDL (no __drizzle_migrations
table), so 0001 was applied directly; the generated migration is committed for
the record.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 00:12:46 +00:00
co-authored by Claude Opus 4.8
parent 449f28b1e5
commit 6b3eb247a3
11 changed files with 1792 additions and 19 deletions
+7 -1
View File
@@ -3,9 +3,12 @@ import { useState, useEffect, useRef } from 'react';
type UseChatWebSocketParams = {
url: string;
onMessage: (data: unknown) => void;
// Called on every (re)connect once the socket is OPEN — used to send a resume-cursor so a reconnect
// re-binds to the session and replays missed events (no manual navigate-away/back).
onOpen?: () => void;
};
export const useChatWebSocket = ({ url, onMessage }: UseChatWebSocketParams) => {
export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketParams) => {
const [isConnected, setIsConnected] = useState(false);
const socketRef = useRef<WebSocket | null>(null);
@@ -14,6 +17,8 @@ export const useChatWebSocket = ({ url, onMessage }: UseChatWebSocketParams) =>
const isCleaningUpRef = useRef(false);
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
const onOpenRef = useRef(onOpen);
onOpenRef.current = onOpen;
const connect = () => {
if (isCleaningUpRef.current) return;
@@ -26,6 +31,7 @@ export const useChatWebSocket = ({ url, onMessage }: UseChatWebSocketParams) =>
if (socketRef.current !== socket) return;
setIsConnected(true);
retryRef.current = 0;
onOpenRef.current?.();
});
socket.addEventListener('message', (ev) => {