diff --git a/.changeset/hydrate-session-posts.md b/.changeset/hydrate-session-posts.md new file mode 100644 index 0000000..a9094b5 --- /dev/null +++ b/.changeset/hydrate-session-posts.md @@ -0,0 +1,5 @@ +--- +"sideshow": patch +--- + +Hydrate session post streams from the session list endpoint to avoid N+1 post detail requests on open. diff --git a/e2e/viewer.spec.ts b/e2e/viewer.spec.ts index 5c8fa55..d7badf9 100644 --- a/e2e/viewer.spec.ts +++ b/e2e/viewer.spec.ts @@ -74,13 +74,19 @@ test("a surface kind this viewer doesn't know shows a refresh hint, not a broken // server returns a valid surface, but rewrite the surface kind to one THIS // viewer build has no Match for. It must degrade to a neutral hint, never // the diff fallback. - await page.route(/\/api\/posts\/[^/?]+(\?|$)/, async (route) => { + await page.route(/\/api\/(posts\/[^/?]+|sessions\/[^/]+\/posts)(\?|$)/, async (route) => { const res = await route.fetch(); - const surface = await res.json(); - if (Array.isArray(surface.surfaces)) { - surface.surfaces = surface.surfaces.map(() => ({ kind: "futurething" })); - } - await route.fulfill({ response: res, json: surface }); + const body = await res.json(); + const rewrite = (post: any) => { + if (Array.isArray(post.surfaces)) { + post.surfaces = post.surfaces.map(() => ({ kind: "futurething" })); + } + return post; + }; + await route.fulfill({ + response: res, + json: Array.isArray(body) ? body.map(rewrite) : rewrite(body), + }); }); await page.goto(server.url); @@ -94,6 +100,46 @@ test("a surface kind this viewer doesn't know shows a refresh hint, not a broken await expect(card.locator(".diff-error")).toHaveCount(0); }); +test("opening a session hydrates posts without N+1 post detail fetches", async ({ + page, + server, +}) => { + const first = await publish(server.url, { + html: "

one

", + title: "One", + agent: "e2e", + sessionTitle: "Hydrate", + }); + await publish(server.url, { + html: "

two

", + title: "Two", + agent: "e2e", + session: first.sessionId, + }); + await publish(server.url, { + html: "

three

", + title: "Three", + agent: "e2e", + session: first.sessionId, + }); + + const postDetailRequests: string[] = []; + const hydratedListRequests: string[] = []; + page.on("request", (req) => { + if (req.method() !== "GET") return; + const url = new URL(req.url()); + if (/^\/api\/posts\/[^/]+$/.test(url.pathname)) postDetailRequests.push(req.url()); + if (url.pathname === `/api/sessions/${first.sessionId}/posts`) { + hydratedListRequests.push(url.searchParams.get("hydrate") ?? ""); + } + }); + + await page.goto(`${server.url}/session/${first.sessionId}`); + await expect(page.locator(".card:not(#whatsNew)")).toHaveCount(3); + expect(hydratedListRequests).toContain("1"); + expect(postDetailRequests).toEqual([]); +}); + test("resize bridge grows the iframe beyond its 120px default", async ({ page, server }) => { const tall = `
tall content
`; await publish(server.url, { html: tall, title: "Tall", agent: "e2e" }); diff --git a/server/app.ts b/server/app.ts index ed83caf..310ba55 100644 --- a/server/app.ts +++ b/server/app.ts @@ -1052,7 +1052,11 @@ export function createApp({ const session = await store.getSession(c.req.param("id")); if (!session) return c.json({ error: "session not found" }, 404); const posts = await store.listPosts(session.id); - return c.json(posts.map(sessionPostListRowView)); + return c.json( + c.req.query("hydrate") === "1" + ? posts.map(postDetailView) + : posts.map(sessionPostListRowView), + ); }; app.get("/api/sessions/:id/surfaces", listSessionPosts); // legacy alias app.get("/api/sessions/:id/posts", listSessionPosts); diff --git a/test/api.test.ts b/test/api.test.ts index 505ce5c..eabb8e2 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -2303,6 +2303,30 @@ test("GET /api/sessions/:id/posts lists lean surfaces with ids and omitted html assert.deepEqual(list[0].parts, list[0].surfaces, "legacy parts aliases surfaces"); }); +test("GET /api/sessions/:id/posts?hydrate=1 returns full post details in one response", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ title: "Hydrated", surfaces: [{ kind: "html", html: "

heavy

" }] }), + ) + ).json()) as any; + await app.request(`/api/posts/${created.id}`, { + ...json({ title: "Hydrated v2", surfaces: [{ kind: "html", html: "

new

" }] }), + method: "PUT", + }); + + const list = (await ( + await app.request(`/api/sessions/${created.sessionId}/posts?hydrate=1`) + ).json()) as any[]; + assert.equal(list.length, 1); + assert.equal(list[0].id, created.id); + assert.equal(list[0].surfaces[0].html, "

new

"); + assert.equal(list[0].surfaces[0].index, 0); + assert.equal(list[0].history[0].surfaces[0].html, "

heavy

"); + assert.equal(list[0].history[0].surfaces[0].index, 0); +}); + test("read responses expose derived surface indexes and renumber after edits", async () => { const app = makeApp(); const created = (await ( diff --git a/viewer/src/state.ts b/viewer/src/state.ts index 4492986..fdb0baf 100644 --- a/viewer/src/state.ts +++ b/viewer/src/state.ts @@ -246,6 +246,29 @@ export async function refreshSessions(targetPostId?: string | null) { } } +function isHydratedPost(value: unknown): value is Post { + return !!value && typeof value === "object" && Array.isArray((value as Post).history); +} + +async function fetchSessionPostDetails(id: string): Promise { + const rows = await api(`/api/sessions/${id}/posts?hydrate=1`).catch(() => []); + const hydrated: Post[] = []; + for (const row of rows) { + if (isHydratedPost(row)) hydrated.push(row); + } + if (hydrated.length === rows.length) return hydrated; + const details = await Promise.all( + rows.map((row) => + row && typeof row === "object" && typeof (row as { id?: unknown }).id === "string" + ? api(`/api/posts/${encodeURIComponent((row as { id: string }).id)}`).catch( + () => null, + ) + : null, + ), + ); + return details.filter((post): post is Post => post !== null); +} + export async function select( id: string, opts?: { fromPopState?: boolean; replace?: boolean; initialPostId?: string }, @@ -272,10 +295,7 @@ export async function select( setCommentsInternal([]); setTraceStepsInternal([]); void fetchTrace(id); - const metas = await api<{ id: string }[]>(`/api/sessions/${id}/posts`).catch(() => []); - const details = ( - await Promise.all(metas.map((m) => api(`/api/posts/${m.id}`).catch(() => null))) - ).filter((s) => s !== null); + const details = await fetchSessionPostDetails(id); if (selected() !== id) return; // user switched away mid-load setPostsInternal(reconcile(details, { key: "id" })); // Scroll to a specific post if requested (deep link). @@ -577,8 +597,8 @@ async function resyncSelected() { await refreshSessions(); if (!before || selected() !== before) return; // select() rebuilt the stream void fetchTrace(before); - const metas = await api<{ id: string }[]>(`/api/sessions/${before}/posts`).catch(() => []); - const ids = new Set(metas.map((m) => m.id)); + const details = await fetchSessionPostDetails(before); + const ids = new Set(details.map((post) => post.id)); setPostsInternal( produce((arr) => { for (let i = arr.length - 1; i >= 0; i--) { @@ -586,7 +606,7 @@ async function resyncSelected() { } }), ); - for (const meta of metas) await upsertPost(meta.id, { scroll: false }); + if (selected() === before) setPostsInternal(reconcile(details, { key: "id" })); const res = await api<{ comments: Comment[] }>(`/api/comments?session=${before}`).catch( () => null, );