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
3 changes: 3 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
30 changes: 28 additions & 2 deletions apps/agor-daemon/src/register-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof userTokenRepo.getTokensForServers>> = 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;
Expand All @@ -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(
Expand Down
44 changes: 43 additions & 1 deletion packages/core/src/db/repositories/user-mcp-oauth-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Map<string, UserMCPOAuthToken>> {
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<string, UserMCPOAuthToken>();
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.
Expand Down