Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/narrow-session-counts.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 10 additions & 3 deletions server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>();
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<string, number>();
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)));
});

Expand Down
10 changes: 10 additions & 0 deletions server/sqlStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,16 @@ export class SqlStore implements Store {
return rows.map((r) => this.rowToPost(r));
}

async countPostsBySession() {
const counts = new Map<string, number>();
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)
Expand Down
9 changes: 9 additions & 0 deletions server/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>();
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()]
Expand Down
5 changes: 5 additions & 0 deletions server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,11 @@ export interface Store {
setSetting(key: string, value: string): Promise<void>;

listPosts(sessionId?: string): Promise<Post[]>;
/**
* 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<Map<string, number>>;
/** The N most-recently-updated posts across all sessions (newest first). */
listRecentPosts(limit: number): Promise<Post[]>;
getPost(id: string): Promise<Post | null>;
Expand Down
54 changes: 52 additions & 2 deletions test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -15,11 +16,15 @@ function makeApp(
screenshots?: boolean;
maxHoldConnections?: number;
onEvent?: Parameters<typeof createApp>[0]["onEvent"];
store?: Store;
},
) {
const dir = mkdtempSync(join(tmpdir(), "sideshow-test-"));
const store = new JsonFileStore(join(dir, "data.json"));
const { viewerHtml = "<html><head></head><body>viewer</body></html>", ...rest } = opts ?? {};
const {
viewerHtml = "<html><head></head><body>viewer</body></html>",
store = new JsonFileStore(join(dir, "data.json")),
...rest
} = opts ?? {};
return createApp({
store,
viewerHtml,
Expand Down Expand Up @@ -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: "<p>one</p>", agent: "pi" }))
).json()) as any;
await app.request(
"/api/snippets",
json({ html: "<p>two</p>", 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: "<p>one</p>", 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) });
Expand Down
23 changes: 23 additions & 0 deletions test/sqlStore.test.ts
Original file line number Diff line number Diff line change
@@ -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("<p>large</p>")] });

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"]);
});
37 changes: 37 additions & 0 deletions test/storeContract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<p>a1</p>")],
});
const a2 = await store.createPost({
sessionId: a.id,
surfaces: [htmlSurface("<p>a2</p>")],
});
await store.createPost({ sessionId: b.id, surfaces: [htmlSurface("<p>b</p>")] });
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({
Expand Down
Loading