diff --git a/.jules/bolt.md b/.jules/bolt.md index ba67a71..6b1d227 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 - [N+1 DB Queries in Sequential Rules Evaluation] +**Learning:** Sequential evaluation of rules or criteria checks (such as checking badge conditions like module completion, streak days, and XP totals inside loop blocks) can lead to an N+1 query pattern where the database is repeatedly queried for identical aggregate values or user profile fields. Sharing or caching the query promises rather than resolved values via a transient cache during the evaluation lifecycle collapses duplicate roundtrips from O(R) to O(1), where R is the number of rules. +**Action:** Utilize a transient context-bound cache containing query promises (e.g., `EvaluationCache`) for evaluations involving multi-rule DB lookups. diff --git a/package.json b/package.json index e60c440..2898b36 100644 --- a/package.json +++ b/package.json @@ -72,5 +72,5 @@ "eslint --fix" ] }, - "packageManager": "pnpm@11.13.0+sha512.88d94724d8f2e6c186744a5584c6e59ecac869ec7ba15e9cb4cd628e8dc7066820b2481d8ee3b51ea8da323a7378068aa58c556a3720d32b7c20a051d088363a" + "packageManager": "pnpm@11.14.0" } diff --git a/src/lib/__tests__/badges.test.ts b/src/lib/__tests__/badges.test.ts index 7cbf60d..c9b041d 100644 --- a/src/lib/__tests__/badges.test.ts +++ b/src/lib/__tests__/badges.test.ts @@ -92,4 +92,79 @@ describe('badges.ts', () => { const result = await evaluateBadges('user-1', { trigger: 'login' }); expect(result.awarded).toEqual([]); }); + + it('awards module_complete badge when criteria met', async () => { + (db.badge.findMany as unknown as ReturnType).mockResolvedValue([ + { id: 'b1', title: 'Module', 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 quiz_score badge when criteria met', async () => { + (db.badge.findMany as unknown as ReturnType).mockResolvedValue([ + { id: 'b1', title: 'Quiz Master', criteria: JSON.stringify({ type: 'quiz_score', threshold: 90 }), xpReward: 40, description: '', icon: '', tier: 'GOLD', isPublished: true, deletedAt: null }, + ]); + (db.userBadge.findMany as unknown as ReturnType).mockResolvedValue([]); + const result = await evaluateBadges('user-1', { trigger: 'quiz_submit', score: 95, passed: true }); + expect(result.awarded).toHaveLength(1); + expect(result.totalXpGained).toBe(40); + }); + + it('does not award quiz_score badge when not passed', async () => { + (db.badge.findMany as unknown as ReturnType).mockResolvedValue([ + { id: 'b1', title: 'Quiz Master', criteria: JSON.stringify({ type: 'quiz_score', threshold: 90 }), xpReward: 40, description: '', icon: '', tier: 'GOLD', isPublished: true, deletedAt: null }, + ]); + (db.userBadge.findMany as unknown as ReturnType).mockResolvedValue([]); + const result = await evaluateBadges('user-1', { trigger: 'quiz_submit', score: 95, passed: false }); + expect(result.awarded).toHaveLength(0); + }); + + it('does not award quiz_score badge when non-quiz trigger', async () => { + (db.badge.findMany as unknown as ReturnType).mockResolvedValue([ + { id: 'b1', title: 'Quiz Master', criteria: JSON.stringify({ type: 'quiz_score', threshold: 90 }), xpReward: 40, description: '', icon: '', tier: 'GOLD', isPublished: true, deletedAt: null }, + ]); + (db.userBadge.findMany as unknown as ReturnType).mockResolvedValue([]); + const result = await evaluateBadges('user-1', { trigger: 'login' }); + expect(result.awarded).toHaveLength(0); + }); + + it('awards tool_sessions badge when criteria met', async () => { + (db.badge.findMany as unknown as ReturnType).mockResolvedValue([ + { id: 'b1', title: 'Tool Pro', criteria: JSON.stringify({ type: 'tool_sessions', threshold: 5 }), xpReward: 50, 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(50); + }); + + it('awards tool_sessions scoped 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: 50, description: '', icon: '', tier: 'SILVER', isPublished: true, deletedAt: null }, + ]); + (db.userBadge.findMany as unknown as ReturnType).mockResolvedValue([]); + (db.toolSession.count as unknown as ReturnType).mockResolvedValue(3); + const result = await evaluateBadges('user-1', { trigger: 'tool_submit', toolType: 'CAMPAIGN_BUILDER', passed: true }); + expect(result.awarded).toHaveLength(1); + expect(result.totalXpGained).toBe(50); + }); + + it('EvaluationCache caches database query promises and prevents N+1 queries', async () => { + (db.badge.findMany as unknown as ReturnType).mockResolvedValue([ + { id: 'b1', title: 'Streak 1', criteria: JSON.stringify({ type: 'streak_days', threshold: 3 }), xpReward: 10, description: '', icon: '', tier: 'BRONZE', isPublished: true, deletedAt: null }, + { id: 'b2', title: 'Streak 2', criteria: JSON.stringify({ type: 'streak_days', threshold: 7 }), xpReward: 20, description: '', icon: '', tier: 'SILVER', isPublished: true, deletedAt: null }, + ]); + (db.userBadge.findMany as unknown as ReturnType).mockResolvedValue([]); + (db.user.findUnique as unknown as ReturnType).mockResolvedValue({ streakDays: 10, xp: 0 }); + + const result = await evaluateBadges('user-1', { trigger: 'login' }); + expect(result.awarded).toHaveLength(2); + // Verified that db.user.findUnique was only called ONCE for both criteria because of EvaluationCache! + expect(db.user.findUnique).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/lib/badges.ts b/src/lib/badges.ts index 6f288eb..03edd25 100644 --- a/src/lib/badges.ts +++ b/src/lib/badges.ts @@ -83,7 +83,7 @@ export async function evaluateBadges( where: { userId }, select: { badgeId: true }, }); - const alreadyAwardedSet = new Set(alreadyAwarded.map((ub) => ub.badgeId)); + const alreadyAwardedSet = new Set(alreadyAwarded.map((ub: { badgeId: string }) => ub.badgeId)); const earnedNow: Array<{ id: string; @@ -95,6 +95,9 @@ export async function evaluateBadges( xpReward: number; }> = []; + // Transient EvaluationCache holds query promises to avoid N+1 query pattern during evaluation + const cache = new EvaluationCache(); + for (const badge of published) { if (alreadyAwardedSet.has(badge.id)) continue; @@ -106,13 +109,13 @@ 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); } // Persist awards in one transaction so partial failures roll back cleanly. if (earnedNow.length > 0) { - await db.$transaction(async (tx) => { + await db.$transaction(async (tx: Parameters[0]>[0]) => { for (const badge of earnedNow) { await tx.userBadge.create({ data: { userId, badgeId: badge.id }, @@ -142,18 +145,28 @@ export async function evaluateBadges( * Returns true if the user has met the given badge criteria at this moment. * Each branch is a narrow DB read — no writes. */ +class EvaluationCache { + lessonCompletedCount: Promise | null = null; + user: Promise<{ streakDays: number; xp: number } | null> | null = null; + toolSessionCount = new Map>(); +} + +/** + * Returns true if the user has met the given badge criteria at this moment. + * Uses shared promises from EvaluationCache to collapse O(R) database roundtrips to O(1). + */ async function checkCriteria( userId: string, criteria: BadgeCriteria, event: BadgeTrigger, + cache: EvaluationCache, ): Promise { switch (criteria.type) { case 'module_complete': { - const completedCount = await db.lessonProgress.count({ + cache.lessonCompletedCount ??= db.lessonProgress.count({ where: { userId, status: 'COMPLETED' }, }); - // Treat each completed lesson as progress toward module_complete; the - // seed threshold is 1 so this triggers after the first lesson. + const completedCount = await cache.lessonCompletedCount!; return completedCount >= criteria.threshold; } @@ -164,31 +177,38 @@ 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 toolType = criteria.scope?.toolType || 'ALL'; + let promise = cache.toolSessionCount.get(toolType); + if (!promise) { + promise = db.toolSession.count({ + where: { + userId, + status: 'GRADED', + ...(criteria.scope?.toolType ? { toolType: criteria.scope.toolType } : {}), + }, + }); + cache.toolSessionCount.set(toolType, promise); + } + const count = await promise; return count >= criteria.threshold; } case 'streak_days': { - const user = await db.user.findUnique({ + cache.user ??= db.user.findUnique({ where: { id: userId }, - select: { streakDays: true }, + select: { streakDays: true, xp: true }, }); + const user = await cache.user!; if (!user) return false; return user.streakDays >= criteria.threshold; } case 'xp_threshold': { - const user = await db.user.findUnique({ + cache.user ??= db.user.findUnique({ where: { id: userId }, - select: { xp: true }, + select: { streakDays: true, xp: true }, }); + const user = await cache.user!; if (!user) return false; return user.xp >= criteria.threshold; }