From 09028d0114c821f1688051eb9c86a15df20f335b Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 11 Aug 2026 10:21:40 -0400 Subject: [PATCH 1/2] perf(viewer): compact live post updates --- .changeset/compact-live-viewer-posts.md | 5 ++ e2e/viewer.spec.ts | 68 ++++++++++++------ server/apiViews.ts | 51 ++++++++++---- server/app.ts | 9 +++ test/api.test.ts | 91 +++++++++++++++++++++---- viewer/src/Card.tsx | 7 +- viewer/src/SessionTimeline.tsx | 8 +-- viewer/src/api.ts | 2 + viewer/src/state.ts | 79 ++++++++++++++++----- 9 files changed, 251 insertions(+), 69 deletions(-) create mode 100644 .changeset/compact-live-viewer-posts.md diff --git a/.changeset/compact-live-viewer-posts.md b/.changeset/compact-live-viewer-posts.md new file mode 100644 index 0000000..7954442 --- /dev/null +++ b/.changeset/compact-live-viewer-posts.md @@ -0,0 +1,5 @@ +--- +"sideshow": patch +--- + +Stop live viewer updates from downloading a post's complete revision history. Live post refetches now use an explicit compact viewer representation with current render data and a retained-version count, while the existing post detail endpoints keep returning full history. diff --git a/e2e/viewer.spec.ts b/e2e/viewer.spec.ts index ec97c65..a91a31d 100644 --- a/e2e/viewer.spec.ts +++ b/e2e/viewer.spec.ts @@ -339,20 +339,23 @@ 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\/[^/?]+|sessions\/[^/]+\/posts)(\?|$)/, async (route) => { - const res = await route.fetch(); - 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.route( + /\/api\/(posts\/[^/?]+(?:\/viewer)?|sessions\/[^/]+\/posts)(\?|$)/, + async (route) => { + const res = await route.fetch(); + 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); // wait until the page is loaded and its SSE is connected, so the publish @@ -815,16 +818,43 @@ test("the Connect an agent page is reachable directly when sessions already exis await expect(page.locator(".connect-page")).toContainText(`npx add-mcp ${server.url}/mcp`); }); -test("version select appears live after an update", async ({ page, server }) => { - const snippet = await publish(server.url, { html: "

v1

", title: "Doc", agent: "e2e" }); +test("live creates and updates fetch compact viewer posts with retained versions", async ({ + page, + server, +}) => { + const first = await publish(server.url, { + html: "

existing

", + title: "Existing", + agent: "e2e", + }); + const compactRequests: string[] = []; + const fullDetailRequests: string[] = []; + page.on("request", (request) => { + if (request.method() !== "GET") return; + const path = new URL(request.url()).pathname; + if (/^\/api\/posts\/[^/]+\/viewer$/.test(path)) compactRequests.push(path); + if (/^\/api\/posts\/[^/]+$/.test(path)) fullDetailRequests.push(path); + }); - await page.goto(server.url); + await page.goto(`${server.url}/session/${first.sessionId}`); await expect(page.locator(".card .vbadge")).toHaveText("v1"); - await update(server.url, snippet.id, { html: "

v2

" }); + const live = await publish(server.url, { + html: "

v1

", + title: "Live compact", + agent: "e2e", + session: first.sessionId, + }); + const liveCard = page.locator(`.card[data-id="${live.id}"]`); + await expect(liveCard.locator(".card-title")).toHaveText("Live compact"); + + await update(server.url, live.id, { html: "

v2

", title: "Live compact v2" }); - const select = page.locator("select.vbadge"); + await expect(liveCard.locator(".card-title")).toHaveText("Live compact v2"); + const select = liveCard.locator("select.vbadge"); await expect(select).toBeVisible(); await expect(select).toHaveValue("2"); await expect(select.locator("option")).toHaveText(["v2", "v1"]); + await expect.poll(() => compactRequests.filter((path) => path.includes(live.id)).length).toBe(2); + expect(fullDetailRequests).toEqual([]); }); diff --git a/server/apiViews.ts b/server/apiViews.ts index 4da8586..82fcf89 100644 --- a/server/apiViews.ts +++ b/server/apiViews.ts @@ -64,21 +64,46 @@ export const postDetailView = (post: Post) => ({ })), }); -// One session's whole stream, hydrated in a single response (`?hydrate=1`). Same -// envelope as postDetailView — the viewer identifies a hydrated row by `history` -// being an array — minus the bodies it never reads. History is here only to size -// the version dropdown (`history.length`): picking an older version just re-points -// each iframe at /s/:id?part=N&ver=N, so past surfaces are never rendered from -// this payload and reduce to refs. -export const sessionPostHydratedView = (post: Post) => ({ - ...post, - surfaces: post.surfaces.map(hydratedSurfaceView), - history: post.history.map((version) => ({ - ...version, - surfaces: version.surfaces.map(surfaceRef), - })), +// The current surface metadata/data the viewer renders. Sandboxed kinds omit +// their body (the iframe fetches it from /s/:id); native kinds keep their inline +// data. Extra kind-specific fields are intentionally open-ended so a newer +// server can send metadata an older viewer safely ignores. +export interface ViewerSurface { + id?: string; + kind: Surface["kind"]; + index: number; + [key: string]: unknown; +} + +// Compact post representation used only by the live viewer. versionCount is the +// number of retained/renderable versions INCLUDING current; it can be lower than +// `version` after HISTORY_LIMIT rolls old revisions out of the store. +export interface ViewerPost { + id: string; + sessionId: string; + title: string; + surfaces: ViewerSurface[]; + createdAt: string; + updatedAt: string; + version: number; + versionCount: number; +} + +export const viewerPostView = (post: Post): ViewerPost => ({ + id: post.id, + sessionId: post.sessionId, + title: post.title, + surfaces: post.surfaces.map(hydratedSurfaceView) as ViewerSurface[], + createdAt: post.createdAt, + updatedAt: post.updatedAt, + version: post.version, + versionCount: post.history.length + 1, }); +// One session's whole stream, hydrated in a single response (`?hydrate=1`). It +// uses the same compact wire contract as the per-post live-update route. +export const sessionPostHydratedView = viewerPostView; + export const sessionPostListRowView = (post: Post) => { const surfaces = post.surfaces.map(sessionListSurfaceView); return { diff --git a/server/app.ts b/server/app.ts index 1957226..92c9eed 100644 --- a/server/app.ts +++ b/server/app.ts @@ -11,6 +11,7 @@ import { sessionPostHydratedView, sessionPostListRowView, sessionRowView, + viewerPostView, type Feedback, } from "./apiViews.ts"; import { EventBus, type FeedEvent } from "./events.ts"; @@ -1129,6 +1130,14 @@ export function createApp({ if (!post) return c.json({ error: "post not found" }, 404); return c.json(postDetailView(post)); }; + // Viewer-only projection for live create/update refetches. Keep this a + // canonical post subresource: the legacy detail aliases remain byte-for-byte + // on the full postDetailView contract above. + app.get("/api/posts/:id/viewer", async (c) => { + const post = await store.getPost(c.req.param("id")); + if (!post) return c.json({ error: "post not found" }, 404); + return c.json(viewerPostView(post)); + }); app.get("/api/surfaces/:id", getPost); // legacy alias app.get("/api/posts/:id", getPost); app.get("/api/snippets/:id", getPost); // legacy alias diff --git a/test/api.test.ts b/test/api.test.ts index 7f8aa5a..a843aaa 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { test } from "node:test"; import { createApp } from "../server/app.ts"; import { JsonFileStore } from "../server/storage.ts"; -import type { Store } from "../server/types.ts"; +import { HISTORY_LIMIT, type Store } from "../server/types.ts"; function makeApp( authToken?: string, @@ -2370,7 +2370,78 @@ 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 every post the viewer needs in one response", async () => { +test("GET /api/posts/:id/viewer is compact while canonical and legacy details stay full", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ + title: "Viewer v1", + surfaces: [ + { kind: "html", html: "

historical body

" }, + { kind: "json", data: { old: true } }, + ], + }), + ) + ).json()) as any; + await app.request(`/api/posts/${created.id}`, { + ...json({ + title: "Viewer v2", + surfaces: [ + { kind: "markdown", markdown: "# current body" }, + { kind: "json", data: { keep: true } }, + { kind: "image", assetId: "asset-current", alt: "kept image metadata" }, + { kind: "trace", steps: [{ label: "kept trace data" }] }, + ], + }), + method: "PUT", + }); + + const compact = (await (await app.request(`/api/posts/${created.id}/viewer`)).json()) as any; + assert.equal(compact.id, created.id); + assert.equal(compact.title, "Viewer v2"); + assert.equal(compact.version, 2); + assert.equal(compact.versionCount, 2); + assert.ok(!("history" in compact), "compact viewer response omits history entirely"); + assert.ok(!("markdown" in compact.surfaces[0]), "sandboxed current body is omitted"); + assert.deepEqual(compact.surfaces[1].data, { keep: true }, "JSON data is retained"); + assert.equal(compact.surfaces[2].assetId, "asset-current", "image metadata is retained"); + assert.equal(compact.surfaces[3].steps[0].label, "kept trace data", "trace data is retained"); + + const canonical = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + const legacySurface = (await (await app.request(`/api/surfaces/${created.id}`)).json()) as any; + const legacySnippet = (await (await app.request(`/api/snippets/${created.id}`)).json()) as any; + assert.deepEqual(legacySurface, canonical); + assert.deepEqual(legacySnippet, canonical); + assert.equal((await app.request(`/api/surfaces/${created.id}/viewer`)).status, 404); + assert.equal((await app.request(`/api/snippets/${created.id}/viewer`)).status, 404); + assert.equal(canonical.history[0].surfaces[0].html, "

historical body

"); + assert.equal(canonical.surfaces[0].markdown, "# current body"); +}); + +test("viewer versionCount is capped to retained history plus current", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ title: "Rolling", surfaces: [{ kind: "html", html: "

v1

" }] }), + ) + ).json()) as any; + for (let version = 2; version <= HISTORY_LIMIT + 3; version++) { + const response = await app.request(`/api/posts/${created.id}`, { + ...json({ title: `Rolling v${version}` }), + method: "PUT", + }); + assert.equal(response.status, 200); + } + + const compact = (await (await app.request(`/api/posts/${created.id}/viewer`)).json()) as any; + assert.equal(compact.version, HISTORY_LIMIT + 3); + assert.equal(compact.versionCount, HISTORY_LIMIT + 1); + assert.ok(compact.versionCount < compact.version, "lifetime version can exceed retained count"); +}); + +test("GET /api/sessions/:id/posts?hydrate=1 returns compact ViewerPosts", async () => { const app = makeApp(); const created = (await ( await app.request( @@ -2390,14 +2461,12 @@ test("GET /api/sessions/:id/posts?hydrate=1 returns every post the viewer needs assert.equal(list[0].id, created.id); assert.equal(list[0].title, "Hydrated v2"); assert.equal(list[0].version, 2); + assert.equal(list[0].versionCount, 2); + assert.ok(!("history" in list[0]), "hydrate omits history entirely"); // The frame ref survives — it's what /s/:id?part=N is built from. assert.equal(list[0].surfaces[0].kind, "html"); assert.equal(list[0].surfaces[0].index, 0); - // History is present (the viewer keys "hydrated" off it) and long enough to - // size the version dropdown, but carries no bodies. - assert.equal(list[0].history.length, 1); - assert.equal(list[0].history[0].surfaces[0].index, 0); - assert.equal(list[0].history[0].surfaces[0].kind, "html"); + assert.ok(!("html" in list[0].surfaces[0]), "sandboxed current body is omitted"); }); test("hydrated posts omit sandboxed bodies the viewer never reads, and keep native ones", async () => { @@ -2428,12 +2497,8 @@ test("hydrated posts omit sandboxed bodies the viewer never reads, and keep nati // Sandboxed kinds render in an iframe that fetches its own body from // /s/:id?part=N — the content key is absent, not empty. assert.ok(!("patch" in post.surfaces[0]), "diff patch body is absent"); - // Native kinds render from inline data and must survive intact. - const [older] = post.history; - assert.ok(!("html" in older.surfaces[0]), "history html body is absent"); - assert.ok(!("markdown" in older.surfaces[1]), "history markdown body is absent"); - assert.ok(!("text" in older.surfaces[2]), "history terminal body is absent"); - assert.ok(!("data" in older.surfaces[3]), "history drops native bodies too"); + assert.ok(!("history" in post), "history is represented only by versionCount"); + assert.equal(post.versionCount, 2); // A native surface in the CURRENT version keeps its payload — check via a post // whose latest version holds one. diff --git a/viewer/src/Card.tsx b/viewer/src/Card.tsx index 0331f10..5f5faae 100644 --- a/viewer/src/Card.tsx +++ b/viewer/src/Card.tsx @@ -21,6 +21,7 @@ import { type JsonSurface as JsonSurfaceData, type Post, type TraceSurface as TraceSurfaceData, + type ViewerPost, postLink, postImageLink, } from "./api.ts"; @@ -198,7 +199,7 @@ function pollScrollIntoView(el: HTMLElement, postId: string): () => void { }; } -export function Card(props: { post: Post; standalone?: boolean }) { +export function Card(props: { post: Post | ViewerPost; standalone?: boolean }) { let card!: HTMLDivElement; let fullscreenDialog: HTMLDivElement | undefined; let fullscreenCloseButton: HTMLButtonElement | undefined; @@ -362,8 +363,10 @@ export function Card(props: { post: Post; standalone?: boolean }) { }); const versionRange = (latest: number) => { + const versionCount = + "versionCount" in props.post ? props.post.versionCount : props.post.history.length + 1; const out = []; - for (let v = latest; v >= Math.max(1, latest - props.post.history.length); v--) out.push(v); + for (let v = latest; v >= Math.max(1, latest - versionCount + 1); v--) out.push(v); return out; }; diff --git a/viewer/src/SessionTimeline.tsx b/viewer/src/SessionTimeline.tsx index e808b7a..0539983 100644 --- a/viewer/src/SessionTimeline.tsx +++ b/viewer/src/SessionTimeline.tsx @@ -1,5 +1,5 @@ import { createMemo, createSignal, For, Show } from "solid-js"; -import type { Post, TraceStep } from "./api.ts"; +import type { TraceStep, ViewerPost } from "./api.ts"; import { Card } from "./Card.tsx"; import { streamLoading, posts, traceSteps } from "./state.ts"; @@ -13,14 +13,14 @@ import { streamLoading, posts, traceSteps } from "./state.ts"; // with posts by time. interface Gap { - post: Post | null; // the post this gap leads into; null = trailing + post: ViewerPost | null; // the post this gap leads into; null = trailing steps: TraceStep[]; } -function buildGaps(postList: readonly Post[], steps: readonly TraceStep[]): Gap[] { +function buildGaps(postList: readonly ViewerPost[], steps: readonly TraceStep[]): Gap[] { const gaps: Gap[] = postList.map((s) => ({ post: s, steps: [] })); gaps.push({ post: null, steps: [] }); - const at = (s: Post) => Date.parse(s.createdAt); + const at = (s: ViewerPost) => Date.parse(s.createdAt); for (const step of steps) { const t = step.ts ? Date.parse(step.ts) : NaN; let idx = gaps.length - 1; // default: trailing diff --git a/viewer/src/api.ts b/viewer/src/api.ts index 5d4268a..58de672 100644 --- a/viewer/src/api.ts +++ b/viewer/src/api.ts @@ -16,6 +16,7 @@ import type { TraceSurface, TraceStep, } from "../../server/types.ts"; +import type { ViewerPost } from "../../server/apiViews.ts"; import { host } from "./host.ts"; export type { @@ -34,6 +35,7 @@ export type { TerminalSurface, TraceSurface, TraceStep, + ViewerPost, }; export type PublicReadMode = "session" | "full"; diff --git a/viewer/src/state.ts b/viewer/src/state.ts index ff232d4..62f37c2 100644 --- a/viewer/src/state.ts +++ b/viewer/src/state.ts @@ -12,6 +12,7 @@ import { type Post, type TraceStep, type VersionInfo, + type ViewerPost, } from "./api.ts"; import { host, root, type Route } from "./host.ts"; import { applyTheme } from "./theme.ts"; @@ -75,7 +76,7 @@ export const selected = selectedState; const [standaloneState, setStandaloneInternal] = createSignal(null); export const standalonePost = standaloneState; export const [unread, setUnread] = createSignal>(new Set()); -const [postsStore, setPostsInternal] = createStore([]); +const [postsStore, setPostsInternal] = createStore([]); export const posts = postsStore; const [commentsState, setCommentsInternal] = createSignal([]); export const comments = commentsState; @@ -279,27 +280,69 @@ 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); +function isViewerPost(value: unknown): value is ViewerPost { + if (!value || typeof value !== "object") return false; + const post = value as Partial; + return ( + typeof post.id === "string" && + typeof post.sessionId === "string" && + typeof post.versionCount === "number" && + Array.isArray(post.surfaces) + ); } -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); +// Compatibility bridge for an older server's `?hydrate=1` response (or a full +// detail fallback): reduce its history to the retained count immediately so the +// viewer state always has the compact ViewerPost contract. +function viewerPostFromDetail(post: Post): ViewerPost { + const { history, ...current } = post; + return { + ...current, + surfaces: current.surfaces.map((surface, index) => ({ ...surface, index })), + versionCount: history.length + 1, + }; +} + +function compactViewerPost(value: unknown): ViewerPost | null { + if (isViewerPost(value)) return value; + if ( + value && + typeof value === "object" && + Array.isArray((value as Partial).history) && + Array.isArray((value as Partial).surfaces) + ) { + return viewerPostFromDetail(value as Post); } - if (hydrated.length === rows.length) return hydrated; + return null; +} + +async function fetchViewerPost(id: string): Promise { + const compact = await api(`/api/posts/${encodeURIComponent(id)}/viewer`).catch( + () => null, + ); + return compactViewerPost(compact); +} + +async function fetchSessionPostDetails(id: string): Promise { + const rows = await api(`/api/sessions/${id}/posts?hydrate=1`).catch(() => []); + const hydrated = rows.map(compactViewerPost); + if (hydrated.every((post): post is ViewerPost => post !== null)) return hydrated; + // Compatibility fallback for servers old enough to ignore `?hydrate=1` and + // return list rows. This path runs only during initial/reconnect hydration; + // live updates never fall back to the full-history detail endpoint. 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, - ), + rows.map(async (row, index) => { + if (hydrated[index]) return hydrated[index]; + if (!row || typeof row !== "object" || typeof (row as { id?: unknown }).id !== "string") { + return null; + } + const detail = await api( + `/api/posts/${encodeURIComponent((row as { id: string }).id)}`, + ).catch(() => null); + return detail ? viewerPostFromDetail(detail) : null; + }), ); - return details.filter((post): post is Post => post !== null); + return details.filter((post): post is ViewerPost => post !== null); } export async function select( @@ -403,7 +446,7 @@ export async function selectAdjacent(delta: 1 | -1) { // Fetch a post and insert/update it in the open session's stream. async function upsertPost(id: string, { scroll = true } = {}) { - const s = await api(`/api/posts/${id}`).catch(() => null); + const s = await fetchViewerPost(id); if (!s || s.sessionId !== selected()) return; const idx = posts.findIndex((x) => x.id === s.id); if (idx >= 0) { From 1f81dfec7bafc38a02c5fc97c14fedd09d5b1661 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 11 Aug 2026 10:44:01 -0400 Subject: [PATCH 2/2] test(viewer): cover compact post normalization --- viewer/src/state.ts | 37 +----------------- viewer/src/viewerPost.ts | 38 ++++++++++++++++++ viewer/test/viewerPost.test.ts | 71 ++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 36 deletions(-) create mode 100644 viewer/src/viewerPost.ts create mode 100644 viewer/test/viewerPost.test.ts diff --git a/viewer/src/state.ts b/viewer/src/state.ts index 62f37c2..d0e99b5 100644 --- a/viewer/src/state.ts +++ b/viewer/src/state.ts @@ -16,6 +16,7 @@ import { } from "./api.ts"; import { host, root, type Route } from "./host.ts"; import { applyTheme } from "./theme.ts"; +import { compactViewerPost, viewerPostFromDetail } from "./viewerPost.ts"; // --- URL routing --- // The host owns the URL. The engine renders whatever route host.router.get() @@ -280,42 +281,6 @@ export async function refreshSessions(targetPostId?: string | null) { } } -function isViewerPost(value: unknown): value is ViewerPost { - if (!value || typeof value !== "object") return false; - const post = value as Partial; - return ( - typeof post.id === "string" && - typeof post.sessionId === "string" && - typeof post.versionCount === "number" && - Array.isArray(post.surfaces) - ); -} - -// Compatibility bridge for an older server's `?hydrate=1` response (or a full -// detail fallback): reduce its history to the retained count immediately so the -// viewer state always has the compact ViewerPost contract. -function viewerPostFromDetail(post: Post): ViewerPost { - const { history, ...current } = post; - return { - ...current, - surfaces: current.surfaces.map((surface, index) => ({ ...surface, index })), - versionCount: history.length + 1, - }; -} - -function compactViewerPost(value: unknown): ViewerPost | null { - if (isViewerPost(value)) return value; - if ( - value && - typeof value === "object" && - Array.isArray((value as Partial).history) && - Array.isArray((value as Partial).surfaces) - ) { - return viewerPostFromDetail(value as Post); - } - return null; -} - async function fetchViewerPost(id: string): Promise { const compact = await api(`/api/posts/${encodeURIComponent(id)}/viewer`).catch( () => null, diff --git a/viewer/src/viewerPost.ts b/viewer/src/viewerPost.ts new file mode 100644 index 0000000..6980c9f --- /dev/null +++ b/viewer/src/viewerPost.ts @@ -0,0 +1,38 @@ +import type { ViewerPost } from "../../server/apiViews.ts"; +import type { Post } from "../../server/types.ts"; + +function isViewerPost(value: unknown): value is ViewerPost { + if (!value || typeof value !== "object") return false; + const post = value as Partial; + return ( + typeof post.id === "string" && + typeof post.sessionId === "string" && + typeof post.versionCount === "number" && + Array.isArray(post.surfaces) + ); +} + +// Compatibility bridge for an older server's `?hydrate=1` response (or a full +// detail fallback): reduce its history to the retained count immediately so the +// viewer state always has the compact ViewerPost contract. +export function viewerPostFromDetail(post: Post): ViewerPost { + const { history, ...current } = post; + return { + ...current, + surfaces: current.surfaces.map((surface, index) => ({ ...surface, index })), + versionCount: history.length + 1, + }; +} + +export function compactViewerPost(value: unknown): ViewerPost | null { + if (isViewerPost(value)) return value; + if ( + value && + typeof value === "object" && + Array.isArray((value as Partial).history) && + Array.isArray((value as Partial).surfaces) + ) { + return viewerPostFromDetail(value as Post); + } + return null; +} diff --git a/viewer/test/viewerPost.test.ts b/viewer/test/viewerPost.test.ts new file mode 100644 index 0000000..3344e96 --- /dev/null +++ b/viewer/test/viewerPost.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from "vitest"; +import type { ViewerPost } from "../../server/apiViews.ts"; +import type { Post } from "../../server/types.ts"; +import { compactViewerPost } from "../src/viewerPost.ts"; + +const compact: ViewerPost = { + id: "post-1", + sessionId: "session-1", + title: "Current", + surfaces: [{ id: "surface-1", kind: "html", index: 0 }], + createdAt: "2026-08-11T00:00:00.000Z", + updatedAt: "2026-08-11T00:01:00.000Z", + version: 2, + versionCount: 2, +}; + +test("compactViewerPost accepts the explicit viewer representation", () => { + expect(compactViewerPost(compact)).toBe(compact); +}); + +test("compactViewerPost reduces older detail responses to current render data", () => { + const detail: Post = { + id: "post-1", + sessionId: "session-1", + title: "Current", + surfaces: [ + { id: "surface-1", kind: "html", html: "

current

" }, + { id: "surface-2", kind: "json", data: { current: true } }, + ], + createdAt: "2026-08-11T00:00:00.000Z", + updatedAt: "2026-08-11T00:01:00.000Z", + version: 2, + history: [ + { + version: 1, + title: "Earlier", + surfaces: [{ id: "surface-1", kind: "html", html: "

earlier

" }], + at: "2026-08-11T00:00:00.000Z", + }, + ], + }; + + expect(compactViewerPost(detail)).toEqual({ + id: detail.id, + sessionId: detail.sessionId, + title: detail.title, + surfaces: [ + { id: "surface-1", kind: "html", html: "

current

", index: 0 }, + { id: "surface-2", kind: "json", data: { current: true }, index: 1 }, + ], + createdAt: detail.createdAt, + updatedAt: detail.updatedAt, + version: detail.version, + versionCount: 2, + }); +}); + +test("compactViewerPost rejects malformed rows instead of hydrating partial state", () => { + for (const value of [ + null, + "post-1", + {}, + { ...compact, id: 1 }, + { ...compact, sessionId: null }, + { ...compact, versionCount: "2" }, + { ...compact, surfaces: null }, + { history: [], surfaces: null }, + ]) { + expect(compactViewerPost(value)).toBeNull(); + } +});