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 DB Roundtrips during Badge Evaluation]
**Learning:** Badge evaluation loops through all published badges to verify criteria (completed modules, streak days, XP thresholds, etc.). Evaluating each badge's criteria independently triggers $O(R)$ database roundtrips for identical records (such as fetching user details or lesson counts multiple times), creating a query bottleneck during evaluation.
**Action:** Use a short-lived request-scoped `EvaluationCache` to cache and share database query promises across criteria checks during a single run, safely collapsing database queries from $O(R)$ to $O(1)$.
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.14.0+sha512.66c1ac4c7d4762d6d7dde44c7f3e5a73591ed0a0806e751d4ed32d4f004f25b2285a906b1fd8a9e3e621df3b4e2858bf88e50e0cf626bedbe977fe434a5caf85"
}
19 changes: 19 additions & 0 deletions src/lib/__tests__/badges.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,23 @@ describe('badges.ts', () => {
const result = await evaluateBadges('user-1', { trigger: 'login' });
expect(result.awarded).toEqual([]);
});

it('collapses redundant database queries during a single evaluateBadges run using EvaluationCache', async () => {
// Set up multiple badges that check the same type of criteria
(db.badge.findMany as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 'b1', title: 'Streak 1', slug: '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', slug: 'streak-2', criteria: JSON.stringify({ type: 'streak_days', threshold: 7 }), xpReward: 20, description: '', icon: '', tier: 'SILVER', isPublished: true, deletedAt: null },
{ id: 'b3', title: 'XP 1', slug: 'xp-1', criteria: JSON.stringify({ type: 'xp_threshold', threshold: 500 }), xpReward: 10, description: '', icon: '', tier: 'BRONZE', isPublished: true, deletedAt: null },
{ id: 'b4', title: 'XP 2', slug: 'xp-2', criteria: JSON.stringify({ type: 'xp_threshold', threshold: 1000 }), xpReward: 20, description: '', icon: '', tier: 'SILVER', isPublished: true, deletedAt: null },
]);
(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: 1200 });

const result = await evaluateBadges('user-1', { trigger: 'login' });
expect(result.awarded).toHaveLength(4);

// Because of the transient EvaluationCache, db.user.findUnique should only have been called once
// despite 4 different criteria checks checking user details (streak_days, streak_days, xp_threshold, xp_threshold)
expect(db.user.findUnique).toHaveBeenCalledTimes(1);
});
});
109 changes: 109 additions & 0 deletions src/lib/__tests__/rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { rateLimit } from '../rate-limit';

describe('rateLimit', () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

it('allows hits under the limit', () => {
const key = 'test-key-1';
const limit = 3;
const windowMs = 60_000;

// 1st hit
let res = rateLimit(key, limit, windowMs);
expect(res.allowed).toBe(true);
expect(res.retryAfterSeconds).toBe(0);

// 2nd hit
res = rateLimit(key, limit, windowMs);
expect(res.allowed).toBe(true);
expect(res.retryAfterSeconds).toBe(0);

// 3rd hit
res = rateLimit(key, limit, windowMs);
expect(res.allowed).toBe(true);
expect(res.retryAfterSeconds).toBe(0);
});

it('blocks hits exceeding the limit', () => {
const key = 'test-key-2';
const limit = 2;
const windowMs = 60_000;

// 1st hit
expect(rateLimit(key, limit, windowMs).allowed).toBe(true);
// 2nd hit
expect(rateLimit(key, limit, windowMs).allowed).toBe(true);

// 3rd hit - should be blocked
const blockedRes = rateLimit(key, limit, windowMs);
expect(blockedRes.allowed).toBe(false);
expect(blockedRes.retryAfterSeconds).toBeGreaterThan(0);
});

it('allows hits again after window expires', () => {
const key = 'test-key-3';
const limit = 1;
const windowMs = 10_000;

// 1st hit
expect(rateLimit(key, limit, windowMs).allowed).toBe(true);

// 2nd hit in the same window - blocked
expect(rateLimit(key, limit, windowMs).allowed).toBe(false);

// Advance time by 11 seconds
vi.advanceTimersByTime(11_000);

// 3rd hit should now be allowed
const res = rateLimit(key, limit, windowMs);
expect(res.allowed).toBe(true);
expect(res.retryAfterSeconds).toBe(0);
});

it('correctly calculates retryAfterSeconds', () => {
const key = 'test-key-4';
const limit = 2;
const windowMs = 60_000;

// Hit at t = 0
rateLimit(key, limit, windowMs);

// Advance time by 15 seconds
vi.advanceTimersByTime(15_000);

// Hit at t = 15s
rateLimit(key, limit, windowMs);

// Hit at t = 15s (3rd hit, should be blocked)
const res = rateLimit(key, limit, windowMs);
expect(res.allowed).toBe(false);
// Expected retryAfterSeconds is from the oldest hit (at t = 0) to expiry: 60s - 15s = 45s
expect(res.retryAfterSeconds).toBe(45);
});

it('performs opportunistic cleanup when buckets Map gets very large', () => {
const limit = 2;
const windowMs = 60_000;

// Seed Map with lots of expired keys to trigger cleanup when size > 10,000
// We will generate 10,005 keys, all with expired timestamps
for (let i = 0; i < 10005; i++) {
const key = `expired-key-${i}`;
rateLimit(key, limit, windowMs);
}

// Since they were all added at t = 0, let's advance time by 61 seconds so they are expired
vi.advanceTimersByTime(61_000);

// Now adding one more key should trigger the cleanup loop and delete the expired ones
const res = rateLimit('trigger-cleanup-key', limit, windowMs);
expect(res.allowed).toBe(true);
});
});
77 changes: 58 additions & 19 deletions src/lib/badges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,56 @@ export interface BadgeEvaluationResult {
totalXpGained: number;
}

/**
* Transient evaluation cache that holds and shares database query promises across criteria checks
* during a single evaluateBadges call to collapse database roundtrips from O(R) to O(1).
*/
export class EvaluationCache {
private userId: string;
private completedCountPromise: Promise<number> | null = null;
private toolSessionCountPromises = new Map<string, Promise<number>>();
private userDetailsPromise: Promise<{ xp: number; streakDays: number } | null> | null = null;

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

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

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

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

/**
* 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 +145,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 +158,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 +198,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.getCompletedCount();
// 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 +216,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.getUserDetails();
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.getUserDetails();
if (!user) return false;
return user.xp >= criteria.threshold;
}
Expand Down