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 MCP OAuth tokens with compound keys]
**Learning:** When batching MCP OAuth token queries across multiple servers, the records can contain a mix of per-user tokens (user_id set) and shared tokens (user_id is null). Squashing these into a Map keyed only by `mcp_server_id` overwrites records and causes the wrong token to be injected based on the server's `oauth_mode`.
**Action:** When populating an in-memory Map from a batched database result for MCP tokens, always use a compound key like `<server_id>:<user_id>` to preserve both shared and per-user records, allowing the caller to select the correct one.
31 changes: 29 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,33 @@ export function registerHooks(ctx: RegisterHooksContext): void {
return context;
}

// Pre-fetch tokens for all servers in the result to eliminate N+1 queries.
const items = Array.isArray(context.result)
? context.result
: context.result?.data && Array.isArray(context.result.data)
? context.result.data
: context.result?.mcp_server_id
? [context.result]
: [];

const mcpServerIds = items
.filter((s: MCPServer) => s.auth?.type === 'oauth')
.map((s: MCPServer) => s.mcp_server_id);

const tokensMap = new Map<string, import('@agor/core/db').UserMCPOAuthToken>();
const userTokenRepo = new UserMCPOAuthTokenRepository(db);

if (mcpServerIds.length > 0) {
const tokens = await userTokenRepo.getTokensForServers(
userId as import('@agor/core/types').UserID,
mcpServerIds
);
// Store using a compound key to preserve both shared and per-user tokens
for (const token of tokens) {
tokensMap.set(`${token.mcp_server_id}:${token.user_id ?? 'shared'}`, token);
}
}

const injectToken = async (server: MCPServer) => {
if (server.auth?.type !== 'oauth') {
return server;
Expand All @@ -811,8 +838,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 tokenKey = `${server.mcp_server_id}:${tokenUserId ?? 'shared'}`;
const row = tokensMap.get(tokenKey);

if (!row) {
console.log(
Expand Down
32 changes: 31 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 @@ -240,6 +240,36 @@ export class UserMCPOAuthTokenRepository {
}
}

/**
* Fetch tokens for a list of servers, resolving both per-user and shared-mode tokens.
*/
async getTokensForServers(
userId: UserID | null,
serverIds: MCPServerID[]
): Promise<UserMCPOAuthToken[]> {
if (serverIds.length === 0) return [];
try {
const rows = await select(this.db)
.from(userMcpOauthTokens)
.where(
and(
inArray(userMcpOauthTokens.mcp_server_id, serverIds),
userId === null
? isNull(userMcpOauthTokens.user_id)
: or(eq(userMcpOauthTokens.user_id, userId), isNull(userMcpOauthTokens.user_id))
)
)
.all();

return rows.map(rowToToken);
} catch (error) {
throw new RepositoryError(
`Failed to get OAuth tokens for servers: ${error instanceof Error ? error.message : String(error)}`,
error
);
}
}

async listForUser(userId: UserID): Promise<UserMCPOAuthToken[]> {
try {
const rows = await select(this.db)
Expand Down