diff --git a/.changeset/narrow-session-counts.md b/.changeset/narrow-session-counts.md new file mode 100644 index 0000000..3069623 --- /dev/null +++ b/.changeset/narrow-session-counts.md @@ -0,0 +1,5 @@ +--- +"sideshow": patch +--- + +Make the session-list endpoint count posts through a narrow store aggregate. SQLite no longer selects or decodes post surfaces and history, while the JSON store avoids cloning and sorting posts after loading the workspace. Custom stores without the optional capability keep the existing `listPosts` fallback. diff --git a/server/app.ts b/server/app.ts index 01b79c3..1957226 100644 --- a/server/app.ts +++ b/server/app.ts @@ -1001,9 +1001,16 @@ export function createApp({ // --- sessions --- app.get("/api/sessions", async (c) => { - const [sessions, surfaces] = await Promise.all([store.listSessions(), store.listPosts()]); - const counts = new Map(); - for (const s of surfaces) counts.set(s.sessionId, (counts.get(s.sessionId) ?? 0) + 1); + const countsPromise = store.countPostsBySession + ? store.countPostsBySession() + : store.listPosts().then((posts) => { + const counts = new Map(); + for (const post of posts) { + counts.set(post.sessionId, (counts.get(post.sessionId) ?? 0) + 1); + } + return counts; + }); + const [sessions, counts] = await Promise.all([store.listSessions(), countsPromise]); return c.json(sessions.map((s) => sessionRowView(s, counts.get(s.id) ?? 0))); }); diff --git a/server/sqlStore.ts b/server/sqlStore.ts index adf012e..9cf8b88 100644 --- a/server/sqlStore.ts +++ b/server/sqlStore.ts @@ -377,6 +377,16 @@ export class SqlStore implements Store { return rows.map((r) => this.rowToPost(r)); } + async countPostsBySession() { + const counts = new Map(); + for (const row of this.sql + .exec("SELECT sessionId, COUNT(*) AS count FROM posts GROUP BY sessionId") + .toArray()) { + counts.set(row.sessionId as string, row.count as number); + } + return counts; + } + async listRecentPosts(limit: number) { const rows = this.sql .exec("SELECT * FROM posts ORDER BY updatedAt DESC LIMIT ?", limit) diff --git a/server/storage.ts b/server/storage.ts index 5210062..9c00ff2 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -351,6 +351,15 @@ export class JsonFileStore implements Store { return all.map(clone).sort((a, b) => a.createdAt.localeCompare(b.createdAt)); } + async countPostsBySession() { + await this.load(); + const counts = new Map(); + for (const post of this.surfaces.values()) { + counts.set(post.sessionId, (counts.get(post.sessionId) ?? 0) + 1); + } + return counts; + } + async listRecentPosts(limit: number) { await this.load(); return [...this.surfaces.values()] diff --git a/server/types.ts b/server/types.ts index 3fbc964..1049a06 100644 --- a/server/types.ts +++ b/server/types.ts @@ -372,6 +372,11 @@ export interface Store { setSetting(key: string, value: string): Promise; listPosts(sessionId?: string): Promise; + /** + * Optional narrow aggregate used by the session-list view. Custom stores may + * omit it; the app falls back to listPosts() for source compatibility. + */ + countPostsBySession?(): Promise>; /** The N most-recently-updated posts across all sessions (newest first). */ listRecentPosts(limit: number): Promise; getPost(id: string): Promise; diff --git a/test/api.test.ts b/test/api.test.ts index eb6fa06..7f8aa5a 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -5,6 +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"; function makeApp( authToken?: string, @@ -15,11 +16,15 @@ function makeApp( screenshots?: boolean; maxHoldConnections?: number; onEvent?: Parameters[0]["onEvent"]; + store?: Store; }, ) { const dir = mkdtempSync(join(tmpdir(), "sideshow-test-")); - const store = new JsonFileStore(join(dir, "data.json")); - const { viewerHtml = "viewer", ...rest } = opts ?? {}; + const { + viewerHtml = "viewer", + store = new JsonFileStore(join(dir, "data.json")), + ...rest + } = opts ?? {}; return createApp({ store, viewerHtml, @@ -62,6 +67,51 @@ test("publish without session auto-creates one", async () => { assert.equal(sessions[0].surfaceCount, 1); }); +test("GET /api/sessions uses the narrow post-count capability", async () => { + const dir = mkdtempSync(join(tmpdir(), "sideshow-count-test-")); + const store = new JsonFileStore(join(dir, "data.json")); + const app = makeApp(undefined, { store }); + const first = (await ( + await app.request("/api/snippets", json({ html: "

one

", agent: "pi" })) + ).json()) as any; + await app.request( + "/api/snippets", + json({ html: "

two

", agent: "pi", session: first.sessionId }), + ); + await app.request("/api/sessions", json({ agent: "empty" })); + + store.listPosts = async () => { + throw new Error("the optimized session list must not materialize posts"); + }; + const response = await app.request("/api/sessions"); + assert.equal(response.status, 200); + const sessions = (await response.json()) as any[]; + assert.equal(sessions.find((session) => session.id === first.sessionId).postCount, 2); + assert.equal(sessions.find((session) => session.agent === "empty").postCount, 0); +}); + +test("GET /api/sessions falls back to listPosts for custom stores", async () => { + const dir = mkdtempSync(join(tmpdir(), "sideshow-count-fallback-test-")); + const store: Store = new JsonFileStore(join(dir, "data.json")); + const app = makeApp(undefined, { store }); + await app.request("/api/snippets", json({ html: "

one

", agent: "custom" })); + + let listPostsCalls = 0; + const listPosts = store.listPosts.bind(store); + store.listPosts = async (...args) => { + listPostsCalls++; + return listPosts(...args); + }; + Object.defineProperty(store, "countPostsBySession", { value: undefined }); + + const response = await app.request("/api/sessions"); + assert.equal(response.status, 200); + assert.equal(listPostsCalls, 1); + const [session] = (await response.json()) as any[]; + assert.equal(session.postCount, 1); + assert.equal(session.surfaceCount, 1); +}); + test("onEvent receives published feed events", async () => { const events: unknown[] = []; const app = makeApp(undefined, { onEvent: (event) => events.push(event) }); diff --git a/test/sqlStore.test.ts b/test/sqlStore.test.ts index 07a07a1..25ed552 100644 --- a/test/sqlStore.test.ts +++ b/test/sqlStore.test.ts @@ -1,8 +1,31 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; import { createSqliteStorage } from "../server/sqliteStorage.ts"; import { SqlStore } from "../server/sqlStore.ts"; +import { htmlSurface, type SqlStorage } from "../server/types.ts"; import { runStoreContract } from "./storeContract.ts"; // Runs the shared store contract against SqlStore on node:sqlite (:memory:) — // the same adapter the local server uses on disk, so the contract exercises the // real Node SQLite path rather than a bespoke shim. runStoreContract("SqlStore", () => new SqlStore(createSqliteStorage())); + +test("SqlStore counts posts with one aggregate query and never selects body columns", async () => { + const storage = createSqliteStorage(); + const queries: string[] = []; + const tracked: SqlStorage = { + exec(query, ...bindings) { + queries.push(query.replace(/\s+/g, " ").trim()); + return storage.exec(query, ...bindings); + }, + }; + const store = new SqlStore(tracked); + const session = await store.createSession({ agent: "pi" }); + await store.createPost({ sessionId: session.id, surfaces: [htmlSurface("

large

")] }); + + queries.length = 0; + const counts = await store.countPostsBySession(); + + assert.equal(counts.get(session.id), 1); + assert.deepEqual(queries, ["SELECT sessionId, COUNT(*) AS count FROM posts GROUP BY sessionId"]); +}); diff --git a/test/storeContract.ts b/test/storeContract.ts index 9db5c1e..235abaa 100644 --- a/test/storeContract.ts +++ b/test/storeContract.ts @@ -228,6 +228,43 @@ export function runStoreContract(name: string, makeStore: () => Store | Promise< assert.equal(await store.getPost("missing"), null); }); + contract("counts posts by session without materializing post details", async (store) => { + assert.ok(store.countPostsBySession, "built-in stores expose the narrow count capability"); + const countPostsBySession = store.countPostsBySession.bind(store); + const a = await store.createSession({ agent: "a" }); + const b = await store.createSession({ agent: "b" }); + const empty = await store.createSession({ agent: "empty" }); + + assert.equal((await countPostsBySession()).size, 0); + const a1 = await store.createPost({ + sessionId: a.id, + surfaces: [htmlSurface("

a1

")], + }); + const a2 = await store.createPost({ + sessionId: a.id, + surfaces: [htmlSurface("

a2

")], + }); + await store.createPost({ sessionId: b.id, surfaces: [htmlSurface("

b

")] }); + assert.ok(a1 && a2); + + let counts = await countPostsBySession(); + assert.equal(counts.size, 2); + assert.equal(counts.get(a.id), 2); + assert.equal(counts.get(b.id), 1); + assert.equal(counts.has(empty.id), false, "empty sessions are absent from the aggregate"); + + await store.removePost(a1.id); + counts = await countPostsBySession(); + assert.equal(counts.get(a.id), 1); + assert.equal(counts.get(b.id), 1); + + await store.removeSession(b.id); + counts = await countPostsBySession(); + assert.equal(counts.size, 1); + assert.equal(counts.get(a.id), 1); + assert.equal(counts.has(b.id), false); + }); + contract("supports multi-part surfaces (html + diff + terminal + trace)", async (store) => { const session = await store.createSession({ agent: "pi" }); const surface = await store.createPost({