Skip to content

Commit ad2bbe4

Browse files
committed
web: add store-lite agents cache
1 parent 4794d5a commit ad2bbe4

File tree

7 files changed

+373
-10
lines changed

7 files changed

+373
-10
lines changed

web/src/app/api/healthz/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { NextResponse } from 'next/server'
2-
import { getCachedAgents } from '@/server/agents-data'
2+
import { getCachedAgentsLite } from '@/server/agents-data'
33

44
export const GET = async () => {
55
try {
66
// Warm the cache by fetching agents data
77
// This ensures SEO-critical data is available immediately
8-
const agents = await getCachedAgents()
8+
const agents = await getCachedAgentsLite()
99

1010
return NextResponse.json({
1111
status: 'ok',

web/src/app/sitemap.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { env } from '@codebuff/common/env'
2-
import { getCachedAgents } from '@/server/agents-data'
2+
import { getCachedAgentsLite } from '@/server/agents-data'
33

44
import type { MetadataRoute } from 'next'
55

@@ -28,7 +28,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
2828

2929
// Include agent detail pages and publisher pages derived from cached store data
3030
try {
31-
const agents = await getCachedAgents()
31+
const agents = await getCachedAgentsLite()
3232

3333
const seenPublishers = new Set<string>()
3434
for (const agent of agents) {

web/src/app/store/page.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Metadata } from 'next'
2-
import { getCachedAgents } from '@/server/agents-data'
2+
import { getCachedAgentsLite } from '@/server/agents-data'
33
import AgentStoreClient from './store-client'
44

55
interface PublisherProfileResponse {
@@ -15,7 +15,7 @@ export async function generateMetadata(): Promise<Metadata> {
1515
publisher?: { avatar_url?: string | null }
1616
}> = []
1717
try {
18-
agents = await getCachedAgents()
18+
agents = await getCachedAgentsLite()
1919
} catch (error) {
2020
console.error('[Store] Failed to fetch agents for metadata:', error)
2121
agents = []
@@ -61,7 +61,7 @@ export default async function StorePage({ searchParams }: StorePageProps) {
6161
// Fetch agents data on the server with ISR cache
6262
let agentsData: any[] = []
6363
try {
64-
agentsData = await getCachedAgents()
64+
agentsData = await getCachedAgentsLite()
6565
} catch (error) {
6666
console.error('[Store] Failed to fetch agents data:', error)
6767
agentsData = []

web/src/app/store/store-client.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useMemo, useCallback, memo, useEffect, useRef, useState } from 'react'
44
import { useQuery } from '@tanstack/react-query'
55
import { useSession } from 'next-auth/react'
6+
import { useRouter } from 'next/navigation'
67
import {
78
Search,
89
TrendingUp,
@@ -127,6 +128,7 @@ export default function AgentStoreClient({
127128
session: initialSession,
128129
searchParams,
129130
}: AgentStoreClientProps) {
131+
const router = useRouter()
130132
// Use client-side session for authentication state, but don't block rendering
131133
const { data: clientSession, status: sessionStatus } = useSession()
132134
const session = clientSession || initialSession
@@ -151,6 +153,18 @@ export default function AgentStoreClient({
151153
// Local state for immediate input feedback
152154
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery)
153155

156+
const prefetchedRoutes = useRef<Set<string>>(new Set())
157+
const prefetchRoute = useCallback(
158+
(href: string) => {
159+
if (prefetchedRoutes.current.has(href)) {
160+
return
161+
}
162+
prefetchedRoutes.current.add(href)
163+
router.prefetch(href)
164+
},
165+
[router],
166+
)
167+
154168
const observerRef = useRef<IntersectionObserver | null>(null)
155169
const loadMoreRef = useRef<HTMLDivElement>(null)
156170
const prevFilters = useRef({ searchQuery: '', sortBy: 'cost' })
@@ -388,9 +402,14 @@ export default function AgentStoreClient({
388402
agent: AgentData
389403
isEditorsChoice?: boolean
390404
}) {
405+
const href = `/publishers/${agent.publisher.id}/agents/${agent.id}/${agent.version || '1.0.0'}`
391406
return (
392407
<Link
393-
href={`/publishers/${agent.publisher.id}/agents/${agent.id}/${agent.version || '1.0.0'}`}
408+
href={href}
409+
prefetch={false}
410+
onMouseEnter={() => prefetchRoute(href)}
411+
onFocus={() => prefetchRoute(href)}
412+
onTouchStart={() => prefetchRoute(href)}
394413
className="block group"
395414
>
396415
<Card

web/src/server/__tests__/agents-transform.test.ts

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, it, expect } from '@jest/globals'
2-
import { buildAgentsData, type AgentRow } from '../agents-transform'
2+
import {
3+
buildAgentsData,
4+
buildAgentsDataLite,
5+
type AgentRow,
6+
} from '../agents-transform'
37

48
describe('buildAgentsData', () => {
59
it('dedupes by latest and merges metrics + sorts by weekly_spent', () => {
@@ -255,3 +259,149 @@ describe('buildAgentsData', () => {
255259
})
256260
})
257261
})
262+
263+
describe('buildAgentsDataLite', () => {
264+
it('dedupes by latest, merges metrics, and omits version_stats', () => {
265+
const agents: AgentRow[] = [
266+
{
267+
id: 'base',
268+
version: '1.0.0',
269+
data: { name: 'Base', description: 'desc', tags: ['x'] },
270+
created_at: '2025-01-01T00:00:00.000Z',
271+
publisher: {
272+
id: 'codebuff',
273+
name: 'Codebuff',
274+
verified: true,
275+
avatar_url: null,
276+
},
277+
},
278+
// older duplicate by name should be ignored due to first-seen is latest ordering
279+
{
280+
id: 'base-old',
281+
version: '0.9.0',
282+
data: { name: 'Base', description: 'old' },
283+
created_at: '2024-12-01T00:00:00.000Z',
284+
publisher: {
285+
id: 'codebuff',
286+
name: 'Codebuff',
287+
verified: true,
288+
avatar_url: null,
289+
},
290+
},
291+
{
292+
id: 'reviewer',
293+
version: '2.1.0',
294+
data: { name: 'Reviewer' },
295+
created_at: '2025-01-03T00:00:00.000Z',
296+
publisher: {
297+
id: 'codebuff',
298+
name: 'Codebuff',
299+
verified: true,
300+
avatar_url: null,
301+
},
302+
},
303+
]
304+
305+
const usageMetrics = [
306+
{
307+
publisher_id: 'codebuff',
308+
agent_name: 'Base',
309+
total_invocations: 50,
310+
total_dollars: 100,
311+
avg_cost_per_run: 2,
312+
unique_users: 4,
313+
last_used: new Date('2025-01-05T00:00:00.000Z'),
314+
},
315+
{
316+
publisher_id: 'codebuff',
317+
agent_name: 'reviewer',
318+
total_invocations: 5,
319+
total_dollars: 5,
320+
avg_cost_per_run: 1,
321+
unique_users: 1,
322+
last_used: new Date('2025-01-04T00:00:00.000Z'),
323+
},
324+
]
325+
326+
const weeklyMetrics = [
327+
{
328+
publisher_id: 'codebuff',
329+
agent_name: 'Base',
330+
weekly_runs: 10,
331+
weekly_dollars: 20,
332+
},
333+
{
334+
publisher_id: 'codebuff',
335+
agent_name: 'reviewer',
336+
weekly_runs: 2,
337+
weekly_dollars: 1,
338+
},
339+
]
340+
341+
const out = buildAgentsDataLite({
342+
agents,
343+
usageMetrics: usageMetrics as any,
344+
weeklyMetrics: weeklyMetrics as any,
345+
})
346+
347+
// should have deduped to two agents
348+
expect(out.length).toBe(2)
349+
350+
const base = out.find((a) => a.id === 'base')!
351+
expect(base.name).toBe('Base')
352+
expect(base.weekly_spent).toBe(20)
353+
expect(base.weekly_runs).toBe(10)
354+
expect(base.total_spent).toBe(100)
355+
expect(base.usage_count).toBe(50)
356+
expect(base.avg_cost_per_invocation).toBe(2)
357+
expect(base.unique_users).toBe(4)
358+
expect(base.version_stats).toBeUndefined()
359+
expect(Object.prototype.hasOwnProperty.call(base, 'version_stats')).toBe(
360+
false,
361+
)
362+
363+
// sorted by weekly_spent desc
364+
expect(out[0].weekly_spent! >= out[1].weekly_spent!).toBe(true)
365+
})
366+
367+
it('handles missing metrics gracefully and omits version_stats', () => {
368+
const agents = [
369+
{
370+
id: 'solo',
371+
version: '0.1.0',
372+
data: { description: 'no name provided' },
373+
created_at: new Date('2025-02-01T00:00:00.000Z'),
374+
publisher: {
375+
id: 'codebuff',
376+
name: 'Codebuff',
377+
verified: true,
378+
avatar_url: null,
379+
},
380+
},
381+
] as any
382+
383+
const out = buildAgentsDataLite({
384+
agents,
385+
usageMetrics: [],
386+
weeklyMetrics: [],
387+
})
388+
389+
expect(out).toHaveLength(1)
390+
const a = out[0]
391+
// falls back to id when name missing
392+
expect(a.name).toBe('solo')
393+
// defaults present
394+
expect(a.weekly_spent).toBe(0)
395+
expect(a.weekly_runs).toBe(0)
396+
expect(a.total_spent).toBe(0)
397+
expect(a.usage_count).toBe(0)
398+
expect(a.avg_cost_per_invocation).toBe(0)
399+
expect(a.unique_users).toBe(0)
400+
expect(a.last_used).toBeUndefined()
401+
expect(a.version_stats).toBeUndefined()
402+
expect(Object.prototype.hasOwnProperty.call(a, 'version_stats')).toBe(false)
403+
expect(a.tags).toEqual([])
404+
// created_at normalized to string
405+
expect(typeof a.created_at).toBe('string')
406+
})
407+
})

web/src/server/agents-data.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import db from '@codebuff/internal/db'
22
import * as schema from '@codebuff/internal/db/schema'
33
import { unstable_cache } from 'next/cache'
44
import { sql, eq, and, gte } from 'drizzle-orm'
5-
import { buildAgentsData } from './agents-transform'
5+
import { buildAgentsData, buildAgentsDataLite } from './agents-transform'
66

77
export interface AgentData {
88
id: string
@@ -156,6 +156,82 @@ export const fetchAgentsWithMetrics = async (): Promise<AgentData[]> => {
156156
})
157157
}
158158

159+
export const fetchAgentsWithMetricsLite = async (): Promise<AgentData[]> => {
160+
const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
161+
162+
const agentsPromise = db
163+
.select({
164+
id: schema.agentConfig.id,
165+
version: schema.agentConfig.version,
166+
data: schema.agentConfig.data,
167+
created_at: schema.agentConfig.created_at,
168+
publisher: {
169+
id: schema.publisher.id,
170+
name: schema.publisher.name,
171+
verified: schema.publisher.verified,
172+
avatar_url: schema.publisher.avatar_url,
173+
},
174+
})
175+
.from(schema.agentConfig)
176+
.innerJoin(
177+
schema.publisher,
178+
eq(schema.agentConfig.publisher_id, schema.publisher.id),
179+
)
180+
.orderBy(sql`${schema.agentConfig.created_at} DESC`)
181+
182+
const usageMetricsPromise = db
183+
.select({
184+
publisher_id: schema.agentRun.publisher_id,
185+
agent_name: schema.agentRun.agent_name,
186+
total_invocations: sql<number>`COUNT(*)`,
187+
total_dollars: sql<number>`COALESCE(SUM(${schema.agentRun.total_credits}) / 100.0, 0)`,
188+
avg_cost_per_run: sql<number>`COALESCE(AVG(${schema.agentRun.total_credits}) / 100.0, 0)`,
189+
unique_users: sql<number>`COUNT(DISTINCT ${schema.agentRun.user_id})`,
190+
last_used: sql<Date>`MAX(${schema.agentRun.created_at})`,
191+
})
192+
.from(schema.agentRun)
193+
.where(
194+
and(
195+
eq(schema.agentRun.status, 'completed'),
196+
sql`${schema.agentRun.agent_id} != 'test-agent'`,
197+
sql`${schema.agentRun.publisher_id} IS NOT NULL`,
198+
sql`${schema.agentRun.agent_name} IS NOT NULL`,
199+
),
200+
)
201+
.groupBy(schema.agentRun.publisher_id, schema.agentRun.agent_name)
202+
203+
const weeklyMetricsPromise = db
204+
.select({
205+
publisher_id: schema.agentRun.publisher_id,
206+
agent_name: schema.agentRun.agent_name,
207+
weekly_runs: sql<number>`COUNT(*)`,
208+
weekly_dollars: sql<number>`COALESCE(SUM(${schema.agentRun.total_credits}) / 100.0, 0)`,
209+
})
210+
.from(schema.agentRun)
211+
.where(
212+
and(
213+
eq(schema.agentRun.status, 'completed'),
214+
gte(schema.agentRun.created_at, oneWeekAgo),
215+
sql`${schema.agentRun.agent_id} != 'test-agent'`,
216+
sql`${schema.agentRun.publisher_id} IS NOT NULL`,
217+
sql`${schema.agentRun.agent_name} IS NOT NULL`,
218+
),
219+
)
220+
.groupBy(schema.agentRun.publisher_id, schema.agentRun.agent_name)
221+
222+
const [agents, usageMetrics, weeklyMetrics] = await Promise.all([
223+
agentsPromise,
224+
usageMetricsPromise,
225+
weeklyMetricsPromise,
226+
])
227+
228+
return buildAgentsDataLite({
229+
agents,
230+
usageMetrics,
231+
weeklyMetrics,
232+
})
233+
}
234+
159235
export const getCachedAgents = unstable_cache(
160236
fetchAgentsWithMetrics,
161237
['agents-data'],
@@ -164,3 +240,12 @@ export const getCachedAgents = unstable_cache(
164240
tags: ['agents', 'api', 'store'],
165241
},
166242
)
243+
244+
export const getCachedAgentsLite = unstable_cache(
245+
fetchAgentsWithMetricsLite,
246+
['agents-data-lite'],
247+
{
248+
revalidate: 600, // 10 minutes
249+
tags: ['agents', 'store'],
250+
},
251+
)

0 commit comments

Comments
 (0)