diff --git a/.jules/bolt.md b/.jules/bolt.md index 8c4087435e..f5dfff682f 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -4,3 +4,6 @@ ## 2026-05-16 - [Batch FeathersJS user fetches with $in operator to fix N+1 query] **Learning:** FeathersJS allows passing `$in` clauses through the query parameter (e.g. `user_id: { $in: ownerIds }`). When writing custom Feathers service logic, you can easily parse this array and pass it to Drizzle's `inArray()` to perform a batched query, instead of looping over `service.get(id)` causing N+1 database roundtrips. **Action:** When implementing or updating custom Feathers `find()` methods, extract and parse the `$in` parameters to support batched Drizzle `inArray()` lookups, and always replace `Promise.all(ids.map(id => service.get(id)))` with a single batched `find()` call. +## 2025-02-12 - [Batch FeathersJS MCP OAuth Token Fetches] +**Learning:** When retrieving related database records during a FeathersJS hook loop (e.g. `injectPerUserOAuthTokens` mapping over `context.result`), doing a DB lookup per item causes an N+1 query problem that severely degrades performance for large list endpoints. +**Action:** Extract the necessary IDs from the `context.result` early in the hook, use a batched database query (`inArray` in Drizzle) to fetch all related records at once, and store them in an in-memory `Map` keyed appropriately for O(1) JIT lookups during the individual item injection. diff --git a/apps/agor-daemon/src/register-hooks.ts b/apps/agor-daemon/src/register-hooks.ts index 05321d3669..a0f374f41b 100755 --- a/apps/agor-daemon/src/register-hooks.ts +++ b/apps/agor-daemon/src/register-hooks.ts @@ -798,6 +798,32 @@ export function registerHooks(ctx: RegisterHooksContext): void { return context; } + // Extract server IDs to batch fetch tokens + let servers: MCPServer[] = []; + if (Array.isArray(context.result)) { + servers = context.result; + } else if (context.result?.data && Array.isArray(context.result.data)) { + servers = context.result.data; + } else if (context.result?.mcp_server_id) { + servers = [context.result]; + } + + const serverIds = servers.map((s) => s.mcp_server_id).filter(Boolean); + const userTokenRepo = new UserMCPOAuthTokenRepository(db); + + // Batch fetch tokens + let tokensMap: Awaited> = new Map(); + try { + if (serverIds.length > 0) { + tokensMap = await userTokenRepo.getTokensForServers( + userId as import('@agor/core/types').UserID, + serverIds + ); + } + } catch (e) { + console.warn(`[MCP OAuth] Failed to batch fetch tokens:`, e); + } + const injectToken = async (server: MCPServer) => { if (server.auth?.type !== 'oauth') { return server; @@ -811,8 +837,8 @@ export function registerHooks(ctx: RegisterHooksContext): void { mode === 'per_user' ? (userId as import('@agor/core/types').UserID) : null; try { - const userTokenRepo = new UserMCPOAuthTokenRepository(db); - const row = await userTokenRepo.getToken(tokenUserId, server.mcp_server_id); + const keyUserId = tokenUserId ?? 'shared'; + const row = tokensMap.get(`${server.mcp_server_id}:${keyUserId}`); if (!row) { console.log( diff --git a/packages/core/src/db/repositories/user-mcp-oauth-tokens.ts b/packages/core/src/db/repositories/user-mcp-oauth-tokens.ts index c5ea01f893..a11bfd2228 100644 --- a/packages/core/src/db/repositories/user-mcp-oauth-tokens.ts +++ b/packages/core/src/db/repositories/user-mcp-oauth-tokens.ts @@ -11,7 +11,7 @@ */ import type { MCPServerID, UserID } from '@agor/core/types'; -import { and, eq, isNull } from 'drizzle-orm'; +import { and, eq, inArray, isNull, or } from 'drizzle-orm'; import type { Database } from '../client'; import { deleteFrom, insert, select, update } from '../database-wrapper'; import { @@ -76,6 +76,48 @@ function matchKey(userId: UserID | null, serverId: MCPServerID) { export class UserMCPOAuthTokenRepository { constructor(private db: Database) {} + /** + * Look up tokens for multiple servers in a single batch query. + * Resolves both shared (user_id = NULL) and per-user tokens simultaneously. + */ + async getTokensForServers( + userId: UserID | null, + serverIds: MCPServerID[] + ): Promise> { + if (serverIds.length === 0) { + return new Map(); + } + + try { + const rows = await select(this.db) + .from(userMcpOauthTokens) + .where( + and( + inArray(userMcpOauthTokens.mcp_server_id, serverIds), + or( + userId === null + ? isNull(userMcpOauthTokens.user_id) + : eq(userMcpOauthTokens.user_id, userId), + isNull(userMcpOauthTokens.user_id) + ) + ) + ) + .all(); + + const map = new Map(); + for (const row of rows) { + const tokenUserId = (row.user_id as string | null) ?? 'shared'; + map.set(`${row.mcp_server_id}:${tokenUserId}`, rowToToken(row)); + } + return map; + } catch (error) { + throw new RepositoryError( + `Failed to get OAuth tokens for servers: ${error instanceof Error ? error.message : String(error)}`, + error + ); + } + } + /** * Look up the token row for a (user, server) pair. Pass `null` for userId * to read the shared-mode row.