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:
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE "chat_session_events" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"session_id" text NOT NULL,
|
||||
"event" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "idx_chat_session_events_session_id" ON "chat_session_events" USING btree ("session_id","id");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,13 @@
|
||||
"when": 1785018112373,
|
||||
"tag": "0000_eager_sir_ram",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1785110579165,
|
||||
"tag": "0001_omniscient_photon",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -77,5 +77,7 @@ export {
|
||||
markInterruptedJobs,
|
||||
} from './queries/pipeline-jobs';
|
||||
|
||||
export { appendChatEvent, getChatEventsSince, pruneChatEventsOlderThan } from './queries/chat-events';
|
||||
|
||||
export { db } from './db';
|
||||
export * as schema from './schema';
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { eq, and, gt, asc, lt } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { chatSessionEvents } from '../schema';
|
||||
|
||||
/** Append one outbound event to a session's durable log; returns its global cursor id. */
|
||||
export async function appendChatEvent(sessionId: string, event: unknown): Promise<number> {
|
||||
const [row] = await db
|
||||
.insert(chatSessionEvents)
|
||||
.values({ sessionId, event })
|
||||
.returning({ id: chatSessionEvents.id });
|
||||
return row!.id;
|
||||
}
|
||||
|
||||
/** Replay everything a (re)connecting client missed: events for this session with id > cursor. */
|
||||
export async function getChatEventsSince(
|
||||
sessionId: string,
|
||||
cursor: number,
|
||||
): Promise<Array<{ id: number; event: unknown }>> {
|
||||
return db
|
||||
.select({ id: chatSessionEvents.id, event: chatSessionEvents.event })
|
||||
.from(chatSessionEvents)
|
||||
.where(and(eq(chatSessionEvents.sessionId, sessionId), gt(chatSessionEvents.id, cursor)))
|
||||
.orderBy(asc(chatSessionEvents.id));
|
||||
}
|
||||
|
||||
/** Retention: drop events older than the cutoff (called periodically). */
|
||||
export async function pruneChatEventsOlderThan(cutoff: Date): Promise<void> {
|
||||
await db.delete(chatSessionEvents).where(lt(chatSessionEvents.createdAt, cutoff));
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { pgTable, bigserial, text, jsonb, timestamp, index } from 'drizzle-orm/pg-core';
|
||||
|
||||
// Durable per-session event log. Every outbound chat ServerMessage is appended here with a global
|
||||
// monotonic `id` (the cursor). A (re)connecting client sends its last-seen cursor and the server
|
||||
// replays everything since — so background task notifications and turn output that landed while the
|
||||
// client was disconnected are never lost (the durability half of the turn/session decoupling).
|
||||
export const chatSessionEvents = pgTable(
|
||||
'chat_session_events',
|
||||
{
|
||||
id: bigserial('id', { mode: 'number' }).primaryKey(),
|
||||
sessionId: text('session_id').notNull(),
|
||||
event: jsonb('event').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index('idx_chat_session_events_session_id').on(t.sessionId, t.id)],
|
||||
);
|
||||
@@ -5,3 +5,4 @@ export * from './operations';
|
||||
export * from './server';
|
||||
export * from './email';
|
||||
export * from './pipeline-jobs';
|
||||
export * from './chat-events';
|
||||
|
||||
Reference in New Issue
Block a user