From 6f54445001686fb03424c890c9d2b3d2bd67619f Mon Sep 17 00:00:00 2001 From: Dhruv Arora <35871415+dhruv1220@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:16:16 -0700 Subject: [PATCH] perf: reduce dots() N+1 queries to a single batch fetch --- src/server/workspace.ts | 39 +++++++++++++++++++++--------------- tests/workspace.test.ts | 44 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 16 deletions(-) diff --git a/src/server/workspace.ts b/src/server/workspace.ts index e98061c3..bed71e7d 100644 --- a/src/server/workspace.ts +++ b/src/server/workspace.ts @@ -102,23 +102,30 @@ export class WorkspaceStore { return space; } dots(): Dot[] { - return this.db + const rows = this.db .prepare('SELECT * FROM dots ORDER BY createdAt') - .all() - .map((row) => ({ - ...row, - spaceIds: this.db - .prepare( - 'SELECT spaceId FROM dot_spaces WHERE dotId=? ORDER BY spaceId', - ) - .all(String(row.id)) - .map((grant) => String(grant.spaceId)), - researchAllowed: !!row.researchAllowed, - memoryAllowed: !!row.memoryAllowed, - appsAllowed: !!row.appsAllowed, - composioToolkits: JSON.parse(String(row.composioToolkits ?? '[]')), - skillDeliveryEnabled: !!row.skillDeliveryEnabled, - })) as unknown as Dot[]; + .all() as unknown as Dot[]; + // One batch fetch for every dot's space grants instead of one query per + // dot (N+1). Grants are grouped in memory by dot id. + const grants = this.db + .prepare('SELECT dotId, spaceId FROM dot_spaces') + .all() as { dotId: string; spaceId: string }[]; + const spaceIdsByDot = new Map(); + for (const grant of grants) { + const dotId = String(grant.dotId); + const list = spaceIdsByDot.get(dotId); + if (list) list.push(String(grant.spaceId)); + else spaceIdsByDot.set(dotId, [String(grant.spaceId)]); + } + return rows.map((row) => ({ + ...row, + spaceIds: (spaceIdsByDot.get(String(row.id)) ?? []).sort(), + researchAllowed: !!row.researchAllowed, + memoryAllowed: !!row.memoryAllowed, + appsAllowed: !!row.appsAllowed, + composioToolkits: JSON.parse(String(row.composioToolkits ?? '[]')), + skillDeliveryEnabled: !!row.skillDeliveryEnabled, + })) as unknown as Dot[]; } dot(id: string) { return this.dots().find((dot) => dot.id === id); diff --git a/tests/workspace.test.ts b/tests/workspace.test.ts index f5a9ef4f..fa1e7cf8 100644 --- a/tests/workspace.test.ts +++ b/tests/workspace.test.ts @@ -54,3 +54,47 @@ it('migrates legacy Space ownership once and never restores revoked access on re rmSync(dir, { recursive: true, force: true }); } }); + +it('lists dots with one batch query for space grants instead of N+1', () => { + const store = new WorkspaceStore(':memory:', 'owner'); + const design = store.createSpace('Design', 'Design decisions'); + const research = store.createSpace('Research', 'Research notes'); + const multi = store.createDot(design.id, 'Scout', 'Be concise', true, true, [ + design.id, + research.id, + ]); + const single = store.createDot( + research.id, + 'Solo', + 'Work alone', + false, + false, + [research.id], + ); + // A dot with no space grants at all must read back with an empty list. + const db = (store as unknown as { db: import('node:sqlite').DatabaseSync }) + .db; + db.prepare( + 'INSERT INTO dots (id, spaceId, name, instructions, researchAllowed, memoryAllowed, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?)', + ).run('lonely', research.id, 'Lonely', 'Hi', 0, 0, 1); + + let prepares = 0; + const prepare = db.prepare.bind(db); + db.prepare = ((sql: string, ...rest: unknown[]) => { + prepares++; + return (prepare as (...args: unknown[]) => unknown)(sql, ...rest); + }) as typeof db.prepare; + + const dots = store.dots(); + + // Two round-trips total (dots + all dot_spaces), regardless of dot count. + expect(prepares).toBe(2); + const byId = new Map(dots.map((dot) => [dot.id, dot])); + expect(byId.get(multi.id)?.spaceIds).toEqual([design.id, research.id].sort()); + expect(byId.get(single.id)?.spaceIds).toEqual([research.id]); + expect(byId.get('lonely')?.spaceIds).toEqual([]); + // Unrelated columns keep their coercions. + expect(byId.get(multi.id)?.researchAllowed).toBe(true); + expect(byId.get('lonely')?.memoryAllowed).toBe(false); + store.close(); +});