Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +7 to +9

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Correct the query-complexity claim.

Tool-session counts use one query for each distinct scope.toolType. The query count is O(R) in the worst case when every rule has a different scope. The entry must not state that all database roundtrips become O(1).

Use plain language. Define technical terms such as “query promise” and “database roundtrip.”

Proposed fix
-**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.
+**Action:** Create one local `LazyCriteriaCache` for each evaluation. Store a database request when it starts, then reuse it when another rule needs the same data. Lesson counts and user data use one request each. Tool-session counts use one request for each different tool type.

As per coding guidelines, use direct, plain-spoken language and define jargon.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
## 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.
## 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:** Create one local `LazyCriteriaCache` for each evaluation. Store a database request when it starts, then reuse it when another rule needs the same data. Lesson counts and user data use one request each. Tool-session counts use one request for each different tool type.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.jules/bolt.md around lines 7 - 9, Update the “Redundant N+1 DB Queries in
Loop-Based Evaluators” entry to state that caching combines repeated queries,
while tool-session queries remain O(R) in the worst case when each rule has a
distinct scope.toolType. Replace “query promise” with a plain-language
definition such as a pending database request, and define “database roundtrip”
as one request to the database; do not claim all roundtrips become O(1).

Source: Coding guidelines

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,5 @@
"eslint --fix"
]
},
"packageManager": "pnpm@11.13.0+sha512.88d94724d8f2e6c186744a5584c6e59ecac869ec7ba15e9cb4cd628e8dc7066820b2481d8ee3b51ea8da323a7378068aa58c556a3720d32b7c20a051d088363a"
"packageManager": "pnpm@11.11.0"
}
54 changes: 54 additions & 0 deletions src/lib/__tests__/badges.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>).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 },
Comment on lines +98 to +99
]);
(db.userBadge.findMany as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(db.user.findUnique as unknown as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue([]);
(db.lessonProgress.count as unknown as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue([]);
(db.toolSession.count as unknown as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue([]);
(db.lessonProgress.count as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(5);
(db.toolSession.count as unknown as ReturnType<typeof vi.fn>).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);
});
});
73 changes: 54 additions & 19 deletions src/lib/badges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> | null = null;
private toolSessionsPromises = new Map<string, Promise<number>>();
private userPromise: Promise<{ streakDays: number; xp: number } | null> | null = null;

getCompletedCount(userId: string): Promise<number> {
if (!this.completedCountPromise) {
this.completedCountPromise = db.lessonProgress.count({
where: { userId, status: 'COMPLETED' },
});
}
return this.completedCountPromise!;
}

getToolSessionsCount(userId: string, scopeToolType?: string): Promise<number> {
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);
Comment on lines +60 to +85

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep the unscoped cache key separate from all tool types.

scope.toolType can equal "__ALL__" because it is a string. An unscoped rule and a rule scoped to "__ALL__" then share one cache entry. The first query controls both results. This can create or omit persisted badge awards and XP.

Use undefined as the unscoped Map key. Add a regression test that evaluates both scopes.

Proposed fix
-  private toolSessionsPromises = new Map<string, Promise<number>>();
+  private toolSessionsPromises = new Map<string | undefined, Promise<number>>();
...
-    const key = scopeToolType || '__ALL__';
+    const key = scopeToolType;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private toolSessionsPromises = new Map<string, Promise<number>>();
private userPromise: Promise<{ streakDays: number; xp: number } | null> | null = null;
getCompletedCount(userId: string): Promise<number> {
if (!this.completedCountPromise) {
this.completedCountPromise = db.lessonProgress.count({
where: { userId, status: 'COMPLETED' },
});
}
return this.completedCountPromise!;
}
getToolSessionsCount(userId: string, scopeToolType?: string): Promise<number> {
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);
private toolSessionsPromises = new Map<string | undefined, Promise<number>>();
private userPromise: Promise<{ streakDays: number; xp: number } | null> | null = null;
getCompletedCount(userId: string): Promise<number> {
if (!this.completedCountPromise) {
this.completedCountPromise = db.lessonProgress.count({
where: { userId, status: 'COMPLETED' },
});
}
return this.completedCountPromise!;
}
getToolSessionsCount(userId: string, scopeToolType?: string): Promise<number> {
const key = scopeToolType;
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);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/badges.ts` around lines 60 - 85, Update getToolSessionsCount and the
toolSessionsPromises map to use undefined as the unscoped cache key instead of
the "__ALL__" string, while preserving distinct string keys for every scoped
tool type including "__ALL__". Add a regression test that evaluates unscoped and
"__ALL__"-scoped queries and verifies each uses its own cached result.

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
Expand Down Expand Up @@ -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;

Expand All @@ -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);
}

Expand Down Expand Up @@ -146,12 +194,11 @@ async function checkCriteria(
userId: string,
criteria: BadgeCriteria,
event: BadgeTrigger,
cache: LazyCriteriaCache,
): Promise<boolean> {
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;
Expand All @@ -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;
}
Expand Down