Skip to content
98 changes: 98 additions & 0 deletions .claude/rules/server-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
---
description: Server-side caching rules
paths:
- 'src/lib/clients/cache.ts'
- 'src/lib/server/**/cache.ts'
- 'src/features/**/server/cache.ts'
---

# Server-Side Cache

## Core Pattern: `Cache<T>.getOrFetch()`

Use `cache.getOrFetch(key, fetchFn)` for all get-or-fetch operations. Never inline the get/set/if pattern manually — it duplicates logic that `getOrFetch` already provides.

```typescript
export function getCachedFoo(fetchFn: () => Promise<Foo>): Promise<Foo> {
return fooCache.getOrFetch(KEY, fetchFn);
}
```

## Domain Cache Module Structure

Place cache modules at `server/cache.ts` within each domain:

| Domain | Path |
| ------- | ------------------------------------- |
| Shared | `src/lib/server/{domain}/cache.ts` |
| Feature | `src/features/{name}/server/cache.ts` |

Each module exports:

- `getCached*()` — thin wrapper around `cache.getOrFetch()`
- `invalidate*Caches()` — clears all related cache instances
- `dispose*Caches()` — disposes all related cache instances (for test cleanup)

## Invalidation Rules

- Call `invalidate*Caches()` immediately after the DB write (`create`, `update`, `delete`) succeeds — not before, not in a `finally` block.
- Group related caches in a single invalidation function (e.g., `invalidateTaskCaches()` clears both `tasksCache` and `mergedTasksCache`).
- Never invalidate from route handlers — invalidation belongs in the service layer, co-located with the write operation.

## TTL Guidelines

| Data characteristics | TTL |
| ------------------------------ | ---------- |
| Rarely changes (tasks, grades) | 1 hour |
| Moderately changes (votes) | 10 minutes |

Adjust based on production metrics after deployment.

## Testing

Core cache behavior (hit, miss, TTL, error propagation) is tested on `Cache<T>.getOrFetch()` in `src/lib/clients/cache.test.ts`. Domain cache tests cover only domain-specific concerns:

- **Wiring**: wrapper delegates correctly and returns cached value on subsequent calls
- **Key isolation**: different parameters produce different cache keys (e.g., `buildPlacementKey`)
- **Invalidation grouping**: `invalidate*()` clears all related caches

Do not duplicate TTL or error propagation tests in domain cache test files.

Always call `afterAll(() => dispose*Caches())` to prevent timer leaks. Isolate tests with `beforeEach(() => invalidate*Caches())`.

## Type Constraint

`Cache<T extends {}>` — never use `null` or `undefined` as `T`. The cache uses `=== undefined` to detect misses; storing `undefined` would silently bypass the cache on every call.

## Stampede Prevention

`Cache<T>.getOrFetch()` shares a single in-flight `Promise` across concurrent callers for the same key. Do not implement manual dedup on top of it.

## Invalidation Audit

When adding a new DB write function, audit every write path in the same domain for missing `invalidate*Caches()` calls. Initial implementation of `upsertVoteGradeTables()` missed this and served stale data for up to 10 minutes.

## Service Layer Integration

Services call `getCached*()` with a `fetchFn` that performs the DB query. The service does not import `Cache` directly.

```typescript
// In service file
import { getCachedTasksMap } from '$lib/server/tasks/cache';

export async function getTasksByTaskId(): Promise<Map<string, Task>> {
return getCachedTasksMap(async () => {
const tasks = await db.task.findMany();
return new Map(tasks.map((task) => [task.task_id, task]));
});
}
```

Mock cache in service tests to bypass caching:

```typescript
vi.mock('$lib/server/tasks/cache', () => ({
getCachedTasksMap: (fetchFn: () => Promise<unknown>) => fetchFn(),
invalidateTaskCaches: vi.fn(),
}));
```
18 changes: 18 additions & 0 deletions .claude/rules/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,24 @@ const mockFindUnique = (data) => db.task.findUnique.mockResolvedValue(data);
const mockFindMany = (data) => db.task.findMany.mockResolvedValue(data);
```

### Cache Module Tests

Prevent timer leaks and test isolation:

```typescript
afterAll(() => disposeDomainCaches());
beforeEach(() => invalidateDomainCaches());
```

Mock cache modules in service tests so caching is bypassed:

```typescript
vi.mock('$lib/server/tasks/cache', () => ({
getCachedTasksMap: (fetchFn: () => Promise<unknown>) => fetchFn(),
invalidateTaskCaches: vi.fn(),
}));
```

### HTTP Mocking (Nock)

Extract setup into helpers, declare once at describe scope:
Expand Down
55 changes: 55 additions & 0 deletions src/features/votes/server/cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, test, expect, vi, beforeEach, afterEach, afterAll } from 'vitest';

import type { VotedGradeStatistics } from '@prisma/client';
import { TaskGrade } from '$lib/types/task';
import { getCachedVoteStats, invalidateVoteCaches, disposeVoteCaches } from './cache';

const makeStats = (): Map<string, VotedGradeStatistics> =>
new Map([
[
'abc408_d',
{
id: '1',
taskId: 'abc408_d',
grade: TaskGrade.Q1,
isExperimental: false,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
} as unknown as VotedGradeStatistics,
],
]);
const mockStatsFn = () => vi.fn().mockResolvedValue(makeStats());

afterAll(() => disposeVoteCaches());

describe('getCachedVoteStats', () => {
beforeEach(() => invalidateVoteCaches());
afterEach(() => vi.restoreAllMocks());

test('delegates to cache and returns fetched value', async () => {
const fetchFn = mockStatsFn();
const result = await getCachedVoteStats(fetchFn);
expect(fetchFn).toHaveBeenCalledTimes(1);
expect(result.get('abc408_d')?.grade).toBe(TaskGrade.Q1);
});

test('returns cached value on subsequent calls', async () => {
const fetchFn = mockStatsFn();
await getCachedVoteStats(fetchFn);
await getCachedVoteStats(fetchFn);
expect(fetchFn).toHaveBeenCalledTimes(1);
});
});

describe('invalidateVoteCaches', () => {
beforeEach(() => invalidateVoteCaches());
afterEach(() => vi.restoreAllMocks());

test('clears vote stats cache', async () => {
const fetchFn = mockStatsFn();
await getCachedVoteStats(fetchFn);
invalidateVoteCaches();
await getCachedVoteStats(fetchFn);
expect(fetchFn).toHaveBeenCalledTimes(2);
});
});
21 changes: 21 additions & 0 deletions src/features/votes/server/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Cache } from '$lib/clients/cache';
import type { VotedGradeStatistics } from '@prisma/client';

const VOTE_STATS_TTL_MS = 10 * 60 * 1000;
const KEY = 'vote_grade_statistics';
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const cache = new Cache<Map<string, VotedGradeStatistics>>(VOTE_STATS_TTL_MS);

export function getCachedVoteStats(
fetchFn: () => Promise<Map<string, VotedGradeStatistics>>,
): Promise<Map<string, VotedGradeStatistics>> {
return cache.getOrFetch(KEY, fetchFn);
}

export function invalidateVoteCaches(): void {
cache.delete(KEY);
}

export function disposeVoteCaches(): void {
cache.dispose();
}
27 changes: 27 additions & 0 deletions src/features/votes/services/vote_grade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { TaskGrade } from '@prisma/client';

import { getVoteGrade, upsertVoteGradeTables } from './vote_grade';

vi.mock('$features/votes/server/cache', () => ({
invalidateVoteCaches: vi.fn(),
}));

vi.mock('$lib/server/database', () => ({
default: {
voteGrade: {
Expand All @@ -26,6 +30,7 @@ vi.mock('$lib/server/database', () => ({
}));

import prisma from '$lib/server/database';
import { invalidateVoteCaches } from '$features/votes/server/cache';

beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -220,4 +225,26 @@ describe('upsertVoteGradeTables', () => {
}),
);
});

test('invalidates vote caches after successful write', async () => {
const tx = setupTransaction();
tx.voteGrade.findUnique.mockResolvedValue(null);
tx.voteGrade.upsert.mockResolvedValue({});
tx.votedGradeCounter.upsert.mockResolvedValue({});
tx.votedGradeCounter.findMany.mockResolvedValue([{ grade: TaskGrade.Q5, count: 1 }]);
tx.task.findUnique.mockResolvedValue({ grade: TaskGrade.Q3 });

await upsertVoteGradeTables('user-1', 'abc001_a', TaskGrade.Q5);

expect(invalidateVoteCaches).toHaveBeenCalledTimes(1);
});

test('does not invalidate vote caches when grade is unchanged (early return)', async () => {
const tx = setupTransaction();
tx.voteGrade.findUnique.mockResolvedValue({ grade: TaskGrade.Q5 });

await upsertVoteGradeTables('user-1', 'abc001_a', TaskGrade.Q5);

expect(invalidateVoteCaches).not.toHaveBeenCalled();
});
});
15 changes: 11 additions & 4 deletions src/features/votes/services/vote_grade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
MIN_VOTES_FOR_STATISTICS,
MIN_VOTES_FOR_PROVISIONAL_GRADE,
} from '$features/votes/constants/statistics';
import { invalidateVoteCaches } from '$features/votes/server/cache';

export async function getVoteGrade(userId: string, taskId: string): Promise<VoteGradeResult> {
const voteRecord = await prisma.voteGrade.findUnique({
Expand All @@ -32,14 +33,13 @@ export async function upsertVoteGradeTables(
taskId: string,
grade: TaskGrade,
): Promise<{ success: true }> {
await prisma.$transaction(async (tx) => {
const isUpdated = await prisma.$transaction(async (tx) => {
const existing = await tx.voteGrade.findUnique({
where: { userId_taskId: { userId, taskId } },
});

// 冪等性: 既に同じグレードなら何もしない
if (existing?.grade === grade) {
return;
return false;
}

if (existing) {
Expand All @@ -63,11 +63,18 @@ export async function upsertVoteGradeTables(
? MIN_VOTES_FOR_PROVISIONAL_GRADE
: MIN_VOTES_FOR_STATISTICS;
if (total < minVotes) {
return;
return true;
}

await updateVoteStatistics(tx, taskId, latestCounters, minVotes);

return true;
});

if (isUpdated) {
invalidateVoteCaches();
}

return { success: true };
}

Expand Down
4 changes: 4 additions & 0 deletions src/features/votes/services/vote_statistics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ vi.mock('$lib/server/database', () => ({
},
}));

vi.mock('$features/votes/server/cache', () => ({
getCachedVoteStats: (fetchFn: () => Promise<unknown>) => fetchFn(),
}));

import prisma from '$lib/server/database';

beforeEach(() => {
Expand Down
11 changes: 5 additions & 6 deletions src/features/votes/services/vote_statistics.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { default as prisma } from '$lib/server/database';
import { getCachedVoteStats } from '$features/votes/server/cache';

import type { VotedGradeStatistics, VotedGradeCounter, TaskGrade } from '@prisma/client';

/** A task row enriched with estimated grade and total vote count. */
Expand All @@ -15,13 +17,10 @@ export type TaskWithVoteInfo = {
};

export async function getVoteGradeStatistics(): Promise<Map<string, VotedGradeStatistics>> {
const allStats = await prisma.votedGradeStatistics.findMany();
const gradesMap = new Map<string, VotedGradeStatistics>();

allStats.forEach((stat) => {
gradesMap.set(stat.taskId, stat);
return getCachedVoteStats(async () => {
const allStats = await prisma.votedGradeStatistics.findMany();
return new Map(allStats.map((stat) => [stat.taskId, stat]));
});
return gradesMap;
}

export async function getVoteGradeStatisticsForTaskIds(
Expand Down
60 changes: 60 additions & 0 deletions src/features/workbooks/server/cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, test, expect, vi, beforeEach, afterEach, afterAll } from 'vitest';

import {
getCachedWorkbooksByPlacement,
getCachedWorkbooksByUser,
invalidateWorkbookCaches,
disposeWorkbookCaches,
} from './cache';

const mockFetchFn = () => vi.fn().mockResolvedValue([]);

afterAll(() => disposeWorkbookCaches());

describe('getCachedWorkbooksByPlacement', () => {
beforeEach(() => invalidateWorkbookCaches());
afterEach(() => vi.restoreAllMocks());

test('returns cached value on subsequent calls', async () => {
const fetchFn = mockFetchFn();
await getCachedWorkbooksByPlacement('SOLUTION:SEARCH_SIMULATION:false', fetchFn);
await getCachedWorkbooksByPlacement('SOLUTION:SEARCH_SIMULATION:false', fetchFn);
expect(fetchFn).toHaveBeenCalledTimes(1);
});

test('misses cache when key differs', async () => {
const fetchFn = mockFetchFn();
await getCachedWorkbooksByPlacement('SOLUTION:SEARCH_SIMULATION:false', fetchFn);
await getCachedWorkbooksByPlacement('SOLUTION:DYNAMIC_PROGRAMMING:false', fetchFn);
expect(fetchFn).toHaveBeenCalledTimes(2);
});
});

describe('getCachedWorkbooksByUser', () => {
beforeEach(() => invalidateWorkbookCaches());
afterEach(() => vi.restoreAllMocks());

test('returns cached value on subsequent calls', async () => {
const fetchFn = mockFetchFn();
await getCachedWorkbooksByUser(fetchFn);
await getCachedWorkbooksByUser(fetchFn);
expect(fetchFn).toHaveBeenCalledTimes(1);
});
});

describe('invalidateWorkbookCaches', () => {
beforeEach(() => invalidateWorkbookCaches());
afterEach(() => vi.restoreAllMocks());

test('clears both placement and user caches', async () => {
const placementFn = mockFetchFn();
const userFn = mockFetchFn();
await getCachedWorkbooksByPlacement('SOLUTION:SEARCH_SIMULATION:false', placementFn);
await getCachedWorkbooksByUser(userFn);
invalidateWorkbookCaches();
await getCachedWorkbooksByPlacement('SOLUTION:SEARCH_SIMULATION:false', placementFn);
await getCachedWorkbooksByUser(userFn);
expect(placementFn).toHaveBeenCalledTimes(2);
expect(userFn).toHaveBeenCalledTimes(2);
});
});
Loading
Loading