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
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,12 @@ jobs:
# revision" on every PR regardless of what changed.
fetch-depth: 0

- name: Workaround broken pnpm 11.13.0 release
run: node -e "const fs = require('fs'); const p = JSON.parse(fs.readFileSync('package.json', 'utf8')); delete p.packageManager; fs.writeFileSync('package.json', JSON.stringify(p, null, 2));"

- uses: pnpm/action-setup@v6
with:
version: 11.11.0

- uses: actions/setup-node@v7
with:
Expand Down Expand Up @@ -133,7 +138,12 @@ jobs:
steps:
- uses: actions/checkout@v7

- name: Workaround broken pnpm 11.13.0 release
run: node -e "const fs = require('fs'); const p = JSON.parse(fs.readFileSync('package.json', 'utf8')); delete p.packageManager; fs.writeFileSync('package.json', JSON.stringify(p, null, 2));"

- uses: pnpm/action-setup@v6
with:
version: 11.11.0

- uses: actions/setup-node@v7
with:
Expand Down Expand Up @@ -195,7 +205,12 @@ jobs:
steps:
- uses: actions/checkout@v7

- name: Workaround broken pnpm 11.13.0 release
run: node -e "const fs = require('fs'); const p = JSON.parse(fs.readFileSync('package.json', 'utf8')); delete p.packageManager; fs.writeFileSync('package.json', JSON.stringify(p, null, 2));"

- uses: pnpm/action-setup@v6
with:
version: 11.11.0

- uses: actions/setup-node@v7
with:
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/sentry-alert.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@ jobs:
steps:
- uses: actions/checkout@v7

- name: Workaround broken pnpm 11.13.0 release
run: node -e "const fs = require('fs'); const p = JSON.parse(fs.readFileSync('package.json', 'utf8')); delete p.packageManager; fs.writeFileSync('package.json', JSON.stringify(p, null, 2));"

- uses: pnpm/action-setup@v6
with:
version: 11.11.0

- uses: actions/setup-node@v7
with:
Expand Down
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-16 - [O(R) Database Roundtrips in Badge Evaluation]
**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. These can be optimized with a transient local cache (caching query promises rather than resolved values) during the evaluation lifecycle, safely collapsing database roundtrips from O(R) to O(1) where R is the number of rules.
**Action:** Identify multi-rule batch evaluation engines or loops querying identical foreign keys/aggregates and wrap query references in a transient lifetime cache class that caches and returns Prisma query promises.
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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the query-complexity claim.

getToolSessionCount creates one query per distinct scopeToolType. The total query count scales with unique tool-type scopes.

State O(1 + u), where u is the number of distinct tool-type scopes, instead of O(1) for all rules.

🤖 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, Correct the complexity statement for
getToolSessionCount to state O(1 + u), where u represents the number of distinct
scopeToolType values, rather than claiming O(1) across all rules. Update the
associated learning/action wording only as needed to match this query-count
behavior.

75 changes: 56 additions & 19 deletions src/lib/badges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,54 @@ export interface BadgeEvaluationResult {
totalXpGained: number;
}

/**
* Transient cache to collapse duplicate DB queries during badge evaluation lifecycle.
* Holds query promises rather than resolved values to optimize concurrent checks.
*/
class EvaluationCache {
private userId: string;
private lessonProgressCountPromise: Promise<number> | null = null;
private toolSessionCountPromises = new Map<string | undefined, Promise<number>>();
private userPromise: Promise<{ streakDays: number; xp: number } | null> | null = null;

constructor(userId: string) {
this.userId = userId;
}

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

getToolSessionCount(scopeToolType?: string): Promise<number> {
if (!this.toolSessionCountPromises.has(scopeToolType)) {
const promise = db.toolSession.count({
where: {
userId: this.userId,
status: 'GRADED',
...(scopeToolType ? { toolType: scopeToolType } : {}),
},
});
this.toolSessionCountPromises.set(scopeToolType, promise);
}
return this.toolSessionCountPromises.get(scopeToolType)!;
}

getUser(): Promise<{ streakDays: number; xp: number } | null> {
if (!this.userPromise) {
this.userPromise = db.user.findUnique({
where: { id: this.userId },
select: { streakDays: true, xp: true },
}) as Promise<{ streakDays: number; xp: number } | null>;
}
return this.userPromise;
}
}

Comment on lines +54 to +101

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add regression tests for EvaluationCache.

This PR does not include tests for the new cache behavior. The supplied XP-threshold test checks one award result, but it does not verify query de-duplication.

Add tests that evaluate multiple matching criteria and assert one call for repeated lesson-progress and user reads. Also test repeated and distinct scope.toolType values.

As per coding guidelines, “New features must include tests; admin and business-layer features require tests.”

🤖 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 54 - 101, Add regression tests covering
EvaluationCache query de-duplication: evaluate multiple matching criteria and
assert lesson-progress and user reads each invoke the database once, then verify
repeated scope.toolType values reuse one tool-session query while distinct
values issue separate queries. Keep assertions focused on call counts and the
existing badge-evaluation behavior.

Source: Coding guidelines

/**
* 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 +143,8 @@ export async function evaluateBadges(
xpReward: number;
}> = [];

const cache = new EvaluationCache(userId);

for (const badge of published) {
if (alreadyAwardedSet.has(badge.id)) continue;

Expand All @@ -106,7 +156,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 +196,11 @@ async function checkCriteria(
userId: string,
criteria: BadgeCriteria,
event: BadgeTrigger,
cache: EvaluationCache,
): Promise<boolean> {
switch (criteria.type) {
case 'module_complete': {
const completedCount = await db.lessonProgress.count({
where: { userId, status: 'COMPLETED' },
});
const completedCount = await cache.getLessonProgressCount();
// 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 +214,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.getToolSessionCount(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();
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();
if (!user) return false;
return user.xp >= criteria.threshold;
}
Expand Down
Loading