From c88a1e894d21d186dc9dada5889a11295f1f2033 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 5 May 2026 12:23:39 -0500 Subject: [PATCH] Fix company search cross-type pagination Co-Authored-By: Paperclip --- .../__tests__/company-search-service.test.ts | 27 ++++++++++++++++--- server/src/services/company-search.ts | 17 ++++++------ 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/server/src/__tests__/company-search-service.test.ts b/server/src/__tests__/company-search-service.test.ts index a240b2d02b3..ada5a888525 100644 --- a/server/src/__tests__/company-search-service.test.ts +++ b/server/src/__tests__/company-search-service.test.ts @@ -46,12 +46,12 @@ describe("company search query validation", () => { expect(parsed.scope).toBe("all"); }); - it("keeps the internal per-branch fetch limit independent of high offsets", () => { + it("includes offset in the internal per-branch fetch window", () => { const lowOffset = companySearchQuerySchema.parse({ q: "needle", limit: "50", offset: "0" }); const highOffset = companySearchQuerySchema.parse({ q: "needle", limit: "50", offset: "9000" }); - expect(companySearchBranchFetchLimit(lowOffset.limit)).toBe(COMPANY_SEARCH_BRANCH_FETCH_LIMIT); - expect(companySearchBranchFetchLimit(highOffset.limit)).toBe(COMPANY_SEARCH_BRANCH_FETCH_LIMIT); + expect(companySearchBranchFetchLimit(lowOffset.limit, lowOffset.offset)).toBe(51); + expect(companySearchBranchFetchLimit(highOffset.limit, highOffset.offset)).toBe(COMPANY_SEARCH_BRANCH_FETCH_LIMIT); }); }); @@ -370,6 +370,27 @@ describeEmbeddedPostgres("companySearchService", () => { ])); }); + it("applies offset after merging cross-type result ranking", async () => { + const companyId = await createCompany(); + const base = new Date("2026-01-01T00:00:00.000Z").getTime(); + const agentIds = await Promise.all([ + createAgent(companyId, { name: "Needle agent 1", updatedAt: new Date(base + 6_000) }), + createAgent(companyId, { name: "Needle agent 2", updatedAt: new Date(base + 5_000) }), + createAgent(companyId, { name: "Needle agent 3", updatedAt: new Date(base + 4_000) }), + ]); + const projectIds = await Promise.all([ + createProject(companyId, { name: "Needle project 1", updatedAt: new Date(base + 3_000) }), + createProject(companyId, { name: "Needle project 2", updatedAt: new Date(base + 2_000) }), + createProject(companyId, { name: "Needle project 3", updatedAt: new Date(base + 1_000) }), + ]); + + const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "needle", limit: "2", offset: "2" })); + + expect(result.results.map((row) => row.id)).toEqual([agentIds[2], projectIds[0]]); + expect(result.countsByType).toEqual({ issue: 0, agent: 3, project: 3 }); + expect(result.hasMore).toBe(true); + }); + it("escapes underscore and backslash characters in issue phrase and token patterns", async () => { const companyId = await createCompany(); const literalId = await createIssue(companyId, { diff --git a/server/src/services/company-search.ts b/server/src/services/company-search.ts index a8af1ccc6cb..1816e074ce2 100644 --- a/server/src/services/company-search.ts +++ b/server/src/services/company-search.ts @@ -4,6 +4,7 @@ import type { Db } from "@paperclipai/db"; import { agents, companies, issues, projects } from "@paperclipai/db"; import { COMPANY_SEARCH_MAX_LIMIT, + COMPANY_SEARCH_MAX_OFFSET, COMPANY_SEARCH_MAX_TOKENS, type CompanySearchIssueSummary, type CompanySearchQuery, @@ -26,7 +27,7 @@ const FUZZY_PAIR_MEDIUM_MAX_EDITS = 1; const FUZZY_PAIR_SHORT_MAX_EDITS = 0; const FUZZY_IDENTIFIER_SIMILARITY_THRESHOLD = 0.45; const SNIPPET_MAX_CHARS = 240; -export const COMPANY_SEARCH_BRANCH_FETCH_LIMIT = COMPANY_SEARCH_MAX_LIMIT + 1; +export const COMPANY_SEARCH_BRANCH_FETCH_LIMIT = COMPANY_SEARCH_MAX_OFFSET + COMPANY_SEARCH_MAX_LIMIT + 1; type IssueSearchRow = { id: string; @@ -291,9 +292,10 @@ function simpleTextCondition(fields: SQL[], containsPattern: string, tokenArray: return sql`(${sql.join([...phraseConditions, ...tokenConditions], sql` OR `)})`; } -export function companySearchBranchFetchLimit(limit: number) { +export function companySearchBranchFetchLimit(limit: number, offset = 0) { const normalizedLimit = Number.isFinite(limit) ? Math.max(1, Math.floor(limit)) : COMPANY_SEARCH_MAX_LIMIT; - return Math.min(COMPANY_SEARCH_BRANCH_FETCH_LIMIT, normalizedLimit + 1); + const normalizedOffset = Number.isFinite(offset) ? Math.max(0, Math.floor(offset)) : 0; + return Math.min(COMPANY_SEARCH_BRANCH_FETCH_LIMIT, normalizedOffset + normalizedLimit + 1); } export function companySearchService(db: Db) { @@ -324,7 +326,7 @@ export function companySearchService(db: Db) { .where(eq(companies.id, companyId)) .then((rows) => rows[0] ?? null); const prefix = routePrefix(company?.issuePrefix); - const fetchLimit = companySearchBranchFetchLimit(limit); + const fetchLimit = companySearchBranchFetchLimit(limit, offset); const escapedTokens = tokens.map(escapeLikePattern); const tokenArray = sqlTextArray(escapedTokens); const fuzzyTokens = fuzzyEligibleTokens(tokens); @@ -596,7 +598,6 @@ export function companySearchService(db: Db) { )) .orderBy(desc(score), desc(issues.updatedAt), desc(issues.id)) .limit(fetchLimit) - .offset(offset) : []; const simpleCondition = simpleTextCondition([ @@ -618,7 +619,6 @@ export function companySearchService(db: Db) { .where(and(eq(agents.companyId, companyId), simpleCondition)) .orderBy(desc(agents.updatedAt), desc(agents.id)) .limit(fetchLimit) - .offset(offset) : []; const projectCondition = simpleTextCondition([ @@ -637,7 +637,6 @@ export function companySearchService(db: Db) { .where(and(eq(projects.companyId, companyId), isNull(projects.archivedAt), projectCondition)) .orderBy(desc(projects.updatedAt), desc(projects.id)) .limit(fetchLimit) - .offset(offset) : []; const results: CompanySearchResult[] = [ @@ -681,7 +680,7 @@ export function companySearchService(db: Db) { return (right.updatedAt ?? "").localeCompare(left.updatedAt ?? ""); }); - const paged = results.slice(0, limit); + const paged = results.slice(offset, offset + limit); return { query: query.q, normalizedQuery, @@ -690,7 +689,7 @@ export function companySearchService(db: Db) { offset, results: paged, countsByType: makeCounts(results), - hasMore: results.length > limit, + hasMore: results.length > offset + limit, }; }, };