diff --git a/.jules/bolt.md b/.jules/bolt.md index ba67a71..3e6cfb6 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -3,3 +3,7 @@ ## 2026-07-16 - [O(N*M) Nested Loop Lookups in Grading Engines] **Learning:** In interactive scenarios (such as Bid Elevator and STR Triage), grading engines frequently iterate over user decisions and match them against scenario properties (like keywords or search terms). Performing `array.find()` inside loop bodies or filter predicates results in costly O(N*M) lookups. **Action:** Convert arrays to `Map` lookups before entering loops/nested scans. Mapping keys once in O(M) time enables O(1) lookups during execution, transforming the time complexity of the grading logic to O(N + M). + +## 2026-07-17 - [Redundant N+1 DB Queries in Loop-Based Evaluators] +**Learning:** Evaluators checking multiple rules (such as checkCriteria in the badge engine) can generate redundant database queries for identical user records or resource aggregates when looping over each rule. +**Action:** Use a transient, local `LazyCriteriaCache` (caching query promises rather than resolved values) during the evaluation lifecycle. This coalesces identical database queries into a single database call, safely changing database roundtrips from O(R) to O(1) where R is the number of rules. diff --git a/package.json b/package.json index e60c440..a07f35e 100644 --- a/package.json +++ b/package.json @@ -72,5 +72,5 @@ "eslint --fix" ] }, - "packageManager": "pnpm@11.13.0+sha512.88d94724d8f2e6c186744a5584c6e59ecac869ec7ba15e9cb4cd628e8dc7066820b2481d8ee3b51ea8da323a7378068aa58c556a3720d32b7c20a051d088363a" + "packageManager": "pnpm@11.11.0" } diff --git a/src/lib/__tests__/badges.test.ts b/src/lib/__tests__/badges.test.ts index 7cbf60d..ca11a74 100644 --- a/src/lib/__tests__/badges.test.ts +++ b/src/lib/__tests__/badges.test.ts @@ -92,4 +92,58 @@ describe('badges.ts', () => { const result = await evaluateBadges('user-1', { trigger: 'login' }); expect(result.awarded).toEqual([]); }); + + it('minimizes database calls by caching query promises during evaluation', async () => { + (db.badge.findMany as unknown as ReturnType).mockResolvedValue([ + { id: 'b1', title: 'Streak 1', criteria: JSON.stringify({ type: 'streak_days', threshold: 7 }), xpReward: 30, description: '', icon: '', tier: 'SILVER', isPublished: true, deletedAt: null }, + { id: 'b2', title: 'XP 1', criteria: JSON.stringify({ type: 'xp_threshold', threshold: 100 }), xpReward: 50, description: '', icon: '', tier: 'BRONZE', isPublished: true, deletedAt: null }, + ]); + (db.userBadge.findMany as unknown as ReturnType).mockResolvedValue([]); + (db.user.findUnique as unknown as ReturnType).mockResolvedValue({ streakDays: 10, xp: 150 }); + + const result = await evaluateBadges('user-1', { trigger: 'login' }); + expect(result.awarded).toHaveLength(2); + expect(db.user.findUnique).toHaveBeenCalledTimes(1); + }); + + it('awards module_complete badge when criteria met', async () => { + (db.badge.findMany as unknown as ReturnType).mockResolvedValue([ + { id: 'b1', title: 'Module Completed', criteria: JSON.stringify({ type: 'module_complete', threshold: 1 }), xpReward: 20, description: '', icon: '', tier: 'BRONZE', isPublished: true, deletedAt: null }, + ]); + (db.userBadge.findMany as unknown as ReturnType).mockResolvedValue([]); + (db.lessonProgress.count as unknown as ReturnType).mockResolvedValue(2); + + const result = await evaluateBadges('user-1', { trigger: 'lesson_complete' }); + expect(result.awarded).toHaveLength(1); + expect(result.totalXpGained).toBe(20); + }); + + it('awards tool_sessions badge when criteria met', async () => { + (db.badge.findMany as unknown as ReturnType).mockResolvedValue([ + { id: 'b1', title: 'Campaign Builder Pro', criteria: JSON.stringify({ type: 'tool_sessions', threshold: 3, scope: { toolType: 'CAMPAIGN_BUILDER' } }), xpReward: 40, description: '', icon: '', tier: 'SILVER', isPublished: true, deletedAt: null }, + ]); + (db.userBadge.findMany as unknown as ReturnType).mockResolvedValue([]); + (db.toolSession.count as unknown as ReturnType).mockResolvedValue(5); + + const result = await evaluateBadges('user-1', { trigger: 'tool_submit', toolType: 'CAMPAIGN_BUILDER', passed: true }); + expect(result.awarded).toHaveLength(1); + expect(result.totalXpGained).toBe(40); + }); + + it('minimizes database calls for lessonProgress and toolSession counts', async () => { + (db.badge.findMany as unknown as ReturnType).mockResolvedValue([ + { id: 'b1', title: 'Module 1', criteria: JSON.stringify({ type: 'module_complete', threshold: 1 }), xpReward: 10, description: '', icon: '', tier: 'BRONZE', isPublished: true, deletedAt: null }, + { id: 'b2', title: 'Module 2', criteria: JSON.stringify({ type: 'module_complete', threshold: 2 }), xpReward: 20, description: '', icon: '', tier: 'BRONZE', isPublished: true, deletedAt: null }, + { id: 'b3', title: 'Tool 1', criteria: JSON.stringify({ type: 'tool_sessions', threshold: 1, scope: { toolType: 'CAMPAIGN_BUILDER' } }), xpReward: 30, description: '', icon: '', tier: 'SILVER', isPublished: true, deletedAt: null }, + { id: 'b4', title: 'Tool 2', criteria: JSON.stringify({ type: 'tool_sessions', threshold: 3, scope: { toolType: 'CAMPAIGN_BUILDER' } }), xpReward: 40, description: '', icon: '', tier: 'SILVER', isPublished: true, deletedAt: null }, + ]); + (db.userBadge.findMany as unknown as ReturnType).mockResolvedValue([]); + (db.lessonProgress.count as unknown as ReturnType).mockResolvedValue(5); + (db.toolSession.count as unknown as ReturnType).mockResolvedValue(4); + + const result = await evaluateBadges('user-1', { trigger: 'lesson_complete' }); + expect(result.awarded).toHaveLength(4); + expect(db.lessonProgress.count).toHaveBeenCalledTimes(1); + expect(db.toolSession.count).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/lib/badges.ts b/src/lib/badges.ts index 6f288eb..0d6f6ef 100644 --- a/src/lib/badges.ts +++ b/src/lib/badges.ts @@ -51,6 +51,52 @@ export interface BadgeEvaluationResult { totalXpGained: number; } +/** + * Lazy Criteria Cache to avoid O(N) database redundant lookups during a single + * badge evaluation run. + */ +class LazyCriteriaCache { + private completedCountPromise: Promise | null = null; + private toolSessionsPromises = new Map>(); + private userPromise: Promise<{ streakDays: number; xp: number } | null> | null = null; + + getCompletedCount(userId: string): Promise { + if (!this.completedCountPromise) { + this.completedCountPromise = db.lessonProgress.count({ + where: { userId, status: 'COMPLETED' }, + }); + } + return this.completedCountPromise!; + } + + getToolSessionsCount(userId: string, scopeToolType?: string): Promise { + const key = scopeToolType || '__ALL__'; + const existing = this.toolSessionsPromises.get(key); + if (existing) { + return existing; + } + const p = db.toolSession.count({ + where: { + userId, + status: 'GRADED', + ...(scopeToolType ? { toolType: scopeToolType } : {}), + }, + }); + this.toolSessionsPromises.set(key, p); + return p; + } + + getUser(userId: string): Promise<{ streakDays: number; xp: number } | null> { + if (!this.userPromise) { + this.userPromise = db.user.findUnique({ + where: { id: userId }, + select: { streakDays: true, xp: true }, + }); + } + return this.userPromise!; + } +} + /** * Evaluate all badges for a user against the current database state. Award any * newly-earned ones. Idempotent — re-running with no new events returns @@ -95,6 +141,8 @@ export async function evaluateBadges( xpReward: number; }> = []; + const cache = new LazyCriteriaCache(); + for (const badge of published) { if (alreadyAwardedSet.has(badge.id)) continue; @@ -106,7 +154,7 @@ export async function evaluateBadges( continue; } - const qualifies = await checkCriteria(userId, criteria, event); + const qualifies = await checkCriteria(userId, criteria, event, cache); if (qualifies) earnedNow.push(badge); } @@ -146,12 +194,11 @@ async function checkCriteria( userId: string, criteria: BadgeCriteria, event: BadgeTrigger, + cache: LazyCriteriaCache, ): Promise { switch (criteria.type) { case 'module_complete': { - const completedCount = await db.lessonProgress.count({ - where: { userId, status: 'COMPLETED' }, - }); + const completedCount = await cache.getCompletedCount(userId); // Treat each completed lesson as progress toward module_complete; the // seed threshold is 1 so this triggers after the first lesson. return completedCount >= criteria.threshold; @@ -165,30 +212,18 @@ async function checkCriteria( case 'tool_sessions': { const scopeToolType = criteria.scope?.toolType; - const count = await db.toolSession.count({ - where: { - userId, - status: 'GRADED', - ...(scopeToolType ? { toolType: scopeToolType } : {}), - }, - }); + const count = await cache.getToolSessionsCount(userId, scopeToolType); return count >= criteria.threshold; } case 'streak_days': { - const user = await db.user.findUnique({ - where: { id: userId }, - select: { streakDays: true }, - }); + const user = await cache.getUser(userId); if (!user) return false; return user.streakDays >= criteria.threshold; } case 'xp_threshold': { - const user = await db.user.findUnique({ - where: { id: userId }, - select: { xp: true }, - }); + const user = await cache.getUser(userId); if (!user) return false; return user.xp >= criteria.threshold; }