Skip to content
Open
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
39 changes: 23 additions & 16 deletions src/server/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]>();
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);
Expand Down
44 changes: 44 additions & 0 deletions tests/workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});