Skip to content

Commit a0ef2ee

Browse files
garyoclaude
andcommitted
Fix sync replaying stale history on fresh tab
A fresh tab starts with lastEventId = 0, so the first poll to /api/sync/events asked for "all changes id > 0 AND source != me". Each tab generates its own crypto.randomUUID() sourceId, so changes from prior sessions of the same browser are foreign and were returned — up to 50 rows of history from the last 24 hours. The client applies each event's `data` (a full row snapshot) over the current store, so rapid clicks could "snap back" a few seconds later as old per-item snapshots overwrote the just-clicked state, and unrelated items would flip on as their old packed snapshots replayed. On first poll (no Last-Event-ID header) return only the current max changeLog id as a checkpoint with no events. The initial GET /trip-items already reflects all prior changes, so we just need a starting point for forward sync — there's nothing to replay. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4b3ac7c commit a0ef2ee

1 file changed

Lines changed: 27 additions & 9 deletions

File tree

src/pages/api/sync/events.ts

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,40 @@
11
export const prerender = false;
22

33
import type { APIRoute } from 'astro';
4-
import { gt, eq, and, asc, sql } from 'drizzle-orm';
4+
import { gt, eq, and, asc, sql, max } from 'drizzle-orm';
55
import { changeLog } from '../../../../db/schema';
66
import { getDatabaseConnection, getUserId } from '../../../lib/api-helpers';
77

8+
const sseResponse = (body: string) =>
9+
new Response(body, {
10+
headers: {
11+
'Content-Type': 'text/event-stream',
12+
'Cache-Control': 'no-cache',
13+
},
14+
});
15+
816
export const GET: APIRoute = async (context) => {
917
const db = getDatabaseConnection(context.locals);
1018
const userId = getUserId(context.locals);
1119

12-
// Last-Event-ID header (sent automatically by EventSource on reconnect)
20+
// Last-Event-ID header (sent automatically by EventSource on reconnect).
21+
// Absent header = first poll of a fresh tab: don't replay history. Return
22+
// the current max id as a checkpoint so subsequent polls only pick up
23+
// changes from this point forward. The initial GET /trip-items already
24+
// reflects all prior changes.
1325
const lastEventIdHeader = context.request.headers.get('Last-Event-ID');
14-
const parsed = lastEventIdHeader ? parseInt(lastEventIdHeader, 10) : 0;
26+
if (lastEventIdHeader === null) {
27+
const row = await db
28+
.select({ maxId: max(changeLog.id) })
29+
.from(changeLog)
30+
.where(eq(changeLog.clerk_user_id, userId))
31+
.get();
32+
const maxId = row?.maxId ?? 0;
33+
const body = maxId > 0 ? `retry: 3000\n\nid: ${maxId}\n\n` : 'retry: 3000\n\n:heartbeat\n\n';
34+
return sseResponse(body);
35+
}
36+
37+
const parsed = parseInt(lastEventIdHeader, 10);
1538
const lastEventId = Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
1639

1740
// Source ID from query param (to filter out own changes)
@@ -55,10 +78,5 @@ export const GET: APIRoute = async (context) => {
5578
}
5679
}
5780

58-
return new Response(body, {
59-
headers: {
60-
'Content-Type': 'text/event-stream',
61-
'Cache-Control': 'no-cache',
62-
},
63-
});
81+
return sseResponse(body);
6482
};

0 commit comments

Comments
 (0)