From 36cddb256662cb0ac49c3cf522a68c70ec71bd6f Mon Sep 17 00:00:00 2001 From: QA Runner Date: Mon, 20 Jul 2026 11:30:05 -0700 Subject: [PATCH] fix: batch directory-modal project fetch instead of N+1 getProject burst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every directory picker (AddDocumentsModal, UseWorkflowModal, the assistant project selector) loads its "Projects" tab through useDirectoryData, which fired one GET /projects/:id for EVERY existing project the moment the modal opened. Each of those requests costs an auth verification against GoTrue plus ~6 PostgREST queries, so an account with N projects produced an ~8xN-request burst on the Supabase gateway per modal open. Under that burst the gateway genuinely falls over: measured locally against the Supabase CLI stack, overlapping modal-open storms drove GoTrue into Postgres connection exhaustion ("failed to connect ... context deadline exceeded") and produced 467x 500 + 641x 504 on /auth/v1/user in a single run — surfacing to users as failed project creates/loads, and to the e2e suite as the intermittent Kong 502s its specs currently retry around. Fix: GET /projects now accepts ?include=documents and returns each project's documents from one batched query (same attach helpers as GET /projects/:id, run once across all documents), and useDirectoryData uses it. A modal open is now 1 API request and a fixed number of DB queries regardless of project count. After the change the same storm harness produced zero 5xx and zero auth failures. Co-Authored-By: Claude Fable 5 --- backend/src/routes/projects.ts | 47 ++++++++++++++++++- .../app/components/shared/useDirectoryData.ts | 21 +++------ frontend/src/app/lib/mikeApi.ts | 7 ++- 3 files changed, 58 insertions(+), 17 deletions(-) diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index cdf03b4..bea8eca 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -152,9 +152,16 @@ async function attachChatCreatorLabels( } // GET /projects +// Pass ?include=documents to also receive each project's documents in the +// same response. The directory pickers (useDirectoryData) previously fanned +// out one GET /projects/:id per project to obtain those documents; with N +// projects that burst — auth check plus several DB queries per request — +// could overwhelm the Supabase gateway. Batching keeps it at one request +// and a fixed number of queries regardless of project count. projectsRouter.get("/", requireAuth, async (req, res) => { const userId = res.locals.userId as string; const userEmail = res.locals.userEmail as string | undefined; + const includeDocuments = req.query.include === "documents"; const db = createServerSupabase(); const { data, error } = await db.rpc("get_projects_overview", { @@ -163,7 +170,45 @@ projectsRouter.get("/", requireAuth, async (req, res) => { }); if (error) return void res.status(500).json({ detail: error.message }); - res.json(data ?? []); + const projects = (data ?? []) as { id: string }[]; + if (!includeDocuments || projects.length === 0) { + return void res.json(projects); + } + + const { data: docs, error: docsError } = await db + .from("documents") + .select("*") + .in( + "project_id", + projects.map((p) => p.id), + ) + .order("created_at", { ascending: true }); + if (docsError) + return void res.status(500).json({ detail: docsError.message }); + + const docsTyped = (docs ?? []) as unknown as { + id: string; + project_id?: string | null; + user_id?: string | null; + current_version_id?: string | null; + }[]; + await attachLatestVersionNumbers(db, docsTyped); + await attachActiveVersionPaths(db, docsTyped); + await attachDocumentOwnerLabels(db, docsTyped); + + const docsByProject = new Map(); + for (const doc of docsTyped) { + if (!doc.project_id) continue; + const bucket = docsByProject.get(doc.project_id); + if (bucket) bucket.push(doc); + else docsByProject.set(doc.project_id, [doc]); + } + res.json( + projects.map((p) => ({ + ...p, + documents: docsByProject.get(p.id) ?? [], + })), + ); }); // POST /projects diff --git a/frontend/src/app/components/shared/useDirectoryData.ts b/frontend/src/app/components/shared/useDirectoryData.ts index 87e23b9..7d4a500 100644 --- a/frontend/src/app/components/shared/useDirectoryData.ts +++ b/frontend/src/app/components/shared/useDirectoryData.ts @@ -1,11 +1,7 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { - getLibrary, - getProject, - listProjects, -} from "@/app/lib/mikeApi"; +import { getLibrary, listProjects } from "@/app/lib/mikeApi"; import type { Document, LibraryFolder, Project } from "./types"; export type DirectoryTab = "files" | "templates" | "projects"; @@ -45,17 +41,14 @@ async function loadTemplates() { } async function loadProjects() { - const projects = await listProjects(); - const fullProjects = await Promise.all( - projects.map((project) => getProject(project.id)), - ); - const projectCounts = new Map( - projects.map((project) => [project.id, project.document_count ?? 0]), - ); - return fullProjects.map((project) => ({ + // One batched request. Fanning out getProject(id) per project caused an + // N+1 burst on every directory-modal open that could overwhelm the + // Supabase gateway once an account had accumulated projects. + const projects = await listProjects({ includeDocuments: true }); + return projects.map((project) => ({ ...project, document_count: - project.documents?.length ?? projectCounts.get(project.id) ?? 0, + project.documents?.length ?? project.document_count ?? 0, })); } diff --git a/frontend/src/app/lib/mikeApi.ts b/frontend/src/app/lib/mikeApi.ts index ad3959c..6dbaa1e 100644 --- a/frontend/src/app/lib/mikeApi.ts +++ b/frontend/src/app/lib/mikeApi.ts @@ -163,8 +163,11 @@ async function toApiError(response: Response, path: string) { // Projects // --------------------------------------------------------------------------- -export async function listProjects(): Promise { - return apiRequest("/projects"); +export async function listProjects(options?: { + includeDocuments?: boolean; +}): Promise { + const query = options?.includeDocuments ? "?include=documents" : ""; + return apiRequest(`/projects${query}`); } export async function createProject(