Skip to content

Commit daff507

Browse files
adewaleclaude
andcommitted
fix: Route all session reads through DO to fix architectural layering
Phase 34 architectural fix: All API endpoints that read session state now route through the Durable Object (source of truth) instead of reading directly from KV. This ensures pending changes are visible immediately, not just after KV flush on last player disconnect. Changes: - Add GET handler to DO (handleStateRead) for session retrieval - Route GET /api/sessions/:id through DO with KV fallback - Route POST /api/sessions/:id/remix through DO with KV fallback - Route POST /api/sessions/:id/publish through DO with KV fallback - Route HTML meta injection through DO with KV fallback - Route OG image generation through DO with KV fallback - Add remixSessionFromState() and publishSessionFromState() helpers - Add comprehensive tests for FX persistence (DO storage, KV flush) - Add tests for DO state visibility in meta/OG image generation This fixes issues where: - Published sessions had stale data (pre-pending changes) - Remixed sessions copied old state from KV - OG images showed outdated track patterns - HTML meta tags showed old session names All 3830 tests pass. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 71e5638 commit daff507

File tree

5 files changed

+1013
-22
lines changed

5 files changed

+1013
-22
lines changed

app/src/worker/index.ts

Lines changed: 107 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ function checkRateLimit(ip: string): { allowed: boolean; remaining: number; rese
4747
const resetIn = RATE_LIMIT_WINDOW_MS - (now - entry.windowStart);
4848
return { allowed: true, remaining: RATE_LIMIT_MAX_REQUESTS - entry.count, resetIn };
4949
}
50-
import { createSession, getSession, remixSession, publishSession, getSecondsUntilMidnightUTC } from './sessions';
50+
import { createSession, getSession, remixSessionFromState, publishSessionFromState, getSecondsUntilMidnightUTC } from './sessions';
5151
import {
5252
isValidUUID,
5353
validateSessionState,
@@ -176,13 +176,32 @@ export default {
176176
// Session Pages: Inject dynamic meta tags for social sharing & SEO
177177
// Always inject for valid session IDs (not just crawlers) so validation
178178
// tools like OpenGraph.xyz, metatags.io, and schema.org see correct content
179+
// Phase 34: Route through DO for latest state (includes pending changes)
179180
// ========================================================================
180181
if (path.startsWith('/s/')) {
181182
const sessionMatch = path.match(/^\/s\/([a-f0-9-]{36})$/);
182183

183184
if (sessionMatch) {
184185
const sessionId = sessionMatch[1];
185-
const sessionData = await env.SESSIONS.get(`session:${sessionId}`, 'json') as Session | null;
186+
187+
// Phase 34: Get session from DO (source of truth) instead of direct KV read
188+
// This ensures social previews show the latest state including pending changes
189+
let sessionData: Session | null = null;
190+
try {
191+
const doId = env.LIVE_SESSIONS.idFromName(sessionId);
192+
const stub = env.LIVE_SESSIONS.get(doId);
193+
const doResponse = await stub.fetch(new Request(
194+
new URL(`/api/sessions/${sessionId}`, request.url).toString(),
195+
{ method: 'GET' }
196+
));
197+
if (doResponse.ok) {
198+
sessionData = await doResponse.json() as Session;
199+
}
200+
} catch (error) {
201+
// Fall back to KV if DO fails (session might not exist or DO error)
202+
console.log(`[meta] DO fetch failed for ${sessionId}, falling back to KV:`, error);
203+
sessionData = await env.SESSIONS.get(`session:${sessionId}`, 'json') as Session | null;
204+
}
186205

187206
if (sessionData) {
188207
// Fetch the base HTML (index.html for SPA)
@@ -479,6 +498,7 @@ async function handleApiRequest(
479498
}
480499

481500
// POST /api/sessions/:id/remix - Remix a session (create a copy)
501+
// Phase 34: Route through DO to get latest source state (may have pending changes)
482502
if (remixMatch && method === 'POST') {
483503
const sourceId = remixMatch[1];
484504

@@ -488,13 +508,33 @@ async function handleApiRequest(
488508
return jsonError('Invalid session ID format', 400);
489509
}
490510

491-
const result = await remixSession(env, sourceId);
511+
// Get source session from DO (includes pending changes not yet in KV)
512+
const doId = env.LIVE_SESSIONS.idFromName(sourceId);
513+
const stub = env.LIVE_SESSIONS.get(doId);
492514

493-
if (!result) {
515+
let sourceSession: Session | null = null;
516+
try {
517+
const doResponse = await stub.fetch(new Request(
518+
new URL(`/api/sessions/${sourceId}`, request.url).toString(),
519+
{ method: 'GET' }
520+
));
521+
if (doResponse.ok) {
522+
sourceSession = await doResponse.json() as Session;
523+
}
524+
} catch (error) {
525+
console.error(`[remix] DO error for source ${sourceId}:`, error);
526+
// Fall back to KV if DO fails
527+
sourceSession = await getSession(env, sourceId, false);
528+
}
529+
530+
if (!sourceSession) {
494531
await completeLog(404, undefined, 'Session not found');
495532
return jsonError('Session not found', 404);
496533
}
497534

535+
// Create remix using the DO-provided state
536+
const result = await remixSessionFromState(env, sourceId, sourceSession);
537+
498538
if (!result.success) {
499539
if (result.quotaExceeded) {
500540
await completeLog(503, undefined, 'KV quota exceeded');
@@ -529,6 +569,7 @@ async function handleApiRequest(
529569
// ==========================================================================
530570

531571
// POST /api/sessions/:id/publish - Publish a session (make it immutable)
572+
// Phase 34: Route through DO to get latest source state (may have pending changes)
532573
if (publishMatch && method === 'POST') {
533574
const id = publishMatch[1];
534575

@@ -538,13 +579,33 @@ async function handleApiRequest(
538579
return jsonError('Invalid session ID format', 400);
539580
}
540581

541-
const result = await publishSession(env, id);
582+
// Get source session from DO (includes pending changes not yet in KV)
583+
const doId = env.LIVE_SESSIONS.idFromName(id);
584+
const stub = env.LIVE_SESSIONS.get(doId);
585+
586+
let sourceSession: Session | null = null;
587+
try {
588+
const doResponse = await stub.fetch(new Request(
589+
new URL(`/api/sessions/${id}`, request.url).toString(),
590+
{ method: 'GET' }
591+
));
592+
if (doResponse.ok) {
593+
sourceSession = await doResponse.json() as Session;
594+
}
595+
} catch (error) {
596+
console.error(`[publish] DO error for source ${id}:`, error);
597+
// Fall back to KV if DO fails
598+
sourceSession = await getSession(env, id, false);
599+
}
542600

543-
if (!result) {
601+
if (!sourceSession) {
544602
await completeLog(404, undefined, 'Session not found');
545603
return jsonError('Session not found', 404);
546604
}
547605

606+
// Publish using the DO-provided state
607+
const result = await publishSessionFromState(env, id, sourceSession);
608+
548609
if (!result.success) {
549610
if (result.quotaExceeded) {
550611
await completeLog(503, undefined, 'KV quota exceeded');
@@ -592,6 +653,9 @@ async function handleApiRequest(
592653
}
593654

594655
// GET /api/sessions/:id - Get session
656+
// Phase 34: Route through Durable Object to get latest state (source of truth)
657+
// This fixes the architectural violation where we read stale data from KV
658+
// while DO had pending changes not yet persisted.
595659
if (sessionMatch && method === 'GET') {
596660
const id = sessionMatch[1];
597661

@@ -601,23 +665,46 @@ async function handleApiRequest(
601665
return jsonError('Invalid session ID format', 400);
602666
}
603667

604-
const session = await getSession(env, id);
668+
// Route through DO - it will return latest state (including pending changes)
669+
// and merge with KV metadata (name, timestamps, etc.)
670+
const doId = env.LIVE_SESSIONS.idFromName(id);
671+
const stub = env.LIVE_SESSIONS.get(doId);
605672

606-
if (!session) {
607-
await completeLog(404, undefined, 'Session not found');
608-
return jsonError('Session not found', 404);
609-
}
673+
try {
674+
const doResponse = await stub.fetch(new Request(request.url, { method: 'GET' }));
610675

611-
await trackSessionAccessed(env);
612-
await completeLog(200, {
613-
trackCount: session.state.tracks.length,
614-
hasData: session.state.tracks.length > 0,
615-
});
676+
if (doResponse.status === 404) {
677+
await completeLog(404, undefined, 'Session not found');
678+
return jsonError('Session not found', 404);
679+
}
616680

617-
return new Response(JSON.stringify(session), {
618-
status: 200,
619-
headers: { 'Content-Type': 'application/json' },
620-
});
681+
if (!doResponse.ok) {
682+
const errorBody = await doResponse.text();
683+
await completeLog(doResponse.status, undefined, errorBody);
684+
return new Response(errorBody, {
685+
status: doResponse.status,
686+
headers: { 'Content-Type': 'application/json' },
687+
});
688+
}
689+
690+
const session = await doResponse.json() as { state: { tracks: unknown[] } };
691+
692+
await trackSessionAccessed(env);
693+
await completeLog(200, {
694+
trackCount: session.state.tracks.length,
695+
hasData: session.state.tracks.length > 0,
696+
});
697+
698+
return new Response(JSON.stringify(session), {
699+
status: 200,
700+
headers: { 'Content-Type': 'application/json' },
701+
});
702+
} catch (error) {
703+
// If DO fails, log and return error (don't silently fall back to KV)
704+
console.error(`[GET] DO error for session ${id}:`, error);
705+
await completeLog(500, undefined, `DO error: ${error}`);
706+
return jsonError('Failed to retrieve session', 500);
707+
}
621708
}
622709

623710
// PUT /api/sessions/:id - Update session

app/src/worker/live-session.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,9 +209,73 @@ export class LiveSessionDurableObject extends DurableObject<Env> {
209209
return this.handleNameUpdate(request, url);
210210
}
211211

212+
// GET /api/sessions/:id - Retrieve session state through DO (Phase 34)
213+
// This ensures API reads get the latest state from DO (source of truth),
214+
// falling back to KV only if DO has no state loaded.
215+
if (request.method === 'GET') {
216+
return this.handleStateRead(url);
217+
}
218+
212219
return new Response('Not found', { status: 404 });
213220
}
214221

222+
/**
223+
* Phase 34: Handle REST API session reads through the Durable Object
224+
*
225+
* This fixes the architectural violation where GET /api/sessions/:id
226+
* was reading directly from KV, bypassing the DO and returning stale state.
227+
*
228+
* The DO is the source of truth for active sessions. If the DO has state
229+
* loaded (from either DO storage or KV), return that state. This ensures
230+
* that any pending changes not yet persisted to KV are included.
231+
*/
232+
private async handleStateRead(url: URL): Promise<Response> {
233+
// Extract session ID from URL path
234+
const pathParts = url.pathname.split('/');
235+
const sessionIdIndex = pathParts.indexOf('sessions') + 1;
236+
const sessionId = pathParts[sessionIdIndex] || null;
237+
238+
if (!sessionId) {
239+
return new Response(JSON.stringify({ error: 'Session ID required' }), {
240+
status: 400,
241+
headers: { 'Content-Type': 'application/json' },
242+
});
243+
}
244+
245+
// Load state if not already loaded
246+
await this.ensureStateLoaded(sessionId);
247+
248+
// If no state (session doesn't exist), return 404
249+
if (!this.state) {
250+
return new Response(JSON.stringify({ error: 'Session not found' }), {
251+
status: 404,
252+
headers: { 'Content-Type': 'application/json' },
253+
});
254+
}
255+
256+
// Get full session from KV (includes metadata like name, createdAt, etc.)
257+
// but override state with DO's current state (which may have pending changes)
258+
const kvSession = await getSession(this.env, sessionId, false);
259+
if (!kvSession) {
260+
return new Response(JSON.stringify({ error: 'Session not found' }), {
261+
status: 404,
262+
headers: { 'Content-Type': 'application/json' },
263+
});
264+
}
265+
266+
// Merge: KV metadata + DO state (source of truth)
267+
const response = {
268+
...kvSession,
269+
state: this.state, // Override with DO state (includes pending changes)
270+
immutable: this.immutable,
271+
};
272+
273+
return new Response(JSON.stringify(response), {
274+
status: 200,
275+
headers: { 'Content-Type': 'application/json' },
276+
});
277+
}
278+
215279
/**
216280
* Phase 31E: Handle REST API session updates through the Durable Object
217281
*

0 commit comments

Comments
 (0)