Compare commits
10 commits
feature/em
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6539d3e346 | ||
|
|
531da98c24 | ||
|
|
90b0890263 | ||
|
|
690e1ab72e | ||
|
|
349107fa6f | ||
|
|
14d1837ee9 | ||
|
|
5f553930c2 | ||
|
|
47a0becc6e | ||
|
|
89e559a4db | ||
|
|
4f47e18ad9 |
16 changed files with 135 additions and 134 deletions
|
|
@ -29,6 +29,7 @@ jobs:
|
||||||
|
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
needs: quality
|
||||||
steps:
|
steps:
|
||||||
- name: Install tools
|
- name: Install tools
|
||||||
run: apt-get update && apt-get install -y docker.io openssh-client
|
run: apt-get update && apt-get install -y docker.io openssh-client
|
||||||
|
|
|
||||||
|
|
@ -18,3 +18,5 @@ coverage/
|
||||||
|
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
routeTree.gen.ts
|
routeTree.gen.ts
|
||||||
|
|
||||||
|
.pnpm-store/
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@
|
||||||
"dev": "pnpm --filter shared build && pnpm --filter db build && tsx watch src/server.ts",
|
"dev": "pnpm --filter shared build && pnpm --filter db build && tsx watch src/server.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/src/server.js",
|
"start": "node dist/src/server.js",
|
||||||
"test": "vitest"
|
"test": "vitest",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lila/db": "workspace:*",
|
"@lila/db": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { Resend } from "resend";
|
||||||
import { db } from "@lila/db";
|
import { db } from "@lila/db";
|
||||||
import * as schema from "@lila/db/schema";
|
import * as schema from "@lila/db/schema";
|
||||||
|
|
||||||
const resend = new Resend(process.env["RESEND_API_KEY"]);
|
|
||||||
const emailFrom = process.env["EMAIL_FROM"] ?? "noreply@lilastudy.com";
|
const emailFrom = process.env["EMAIL_FROM"] ?? "noreply@lilastudy.com";
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
|
|
@ -30,6 +29,7 @@ export const auth = betterAuth({
|
||||||
user: { email: string };
|
user: { email: string };
|
||||||
url: string;
|
url: string;
|
||||||
}) => {
|
}) => {
|
||||||
|
const resend = new Resend(process.env["RESEND_API_KEY"]);
|
||||||
await resend.emails.send({
|
await resend.emails.send({
|
||||||
from: emailFrom,
|
from: emailFrom,
|
||||||
to: user.email,
|
to: user.email,
|
||||||
|
|
@ -48,6 +48,7 @@ export const auth = betterAuth({
|
||||||
user: { email: string };
|
user: { email: string };
|
||||||
url: string;
|
url: string;
|
||||||
}) => {
|
}) => {
|
||||||
|
const resend = new Resend(process.env["RESEND_API_KEY"]);
|
||||||
await resend.emails.send({
|
await resend.emails.send({
|
||||||
from: emailFrom,
|
from: emailFrom,
|
||||||
to: user.email,
|
to: user.email,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lila/shared": "workspace:*",
|
"@lila/shared": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -90,9 +90,6 @@ Directionally right, timing is unclear. Revisit when the next/now work is done.
|
||||||
- **Resolve eslint peer dependency warning** `[debt]`
|
- **Resolve eslint peer dependency warning** `[debt]`
|
||||||
`eslint-plugin-react-hooks 7.0.1` expects `eslint ^3.0.0–^9.0.0` but found `10.0.3`. Low impact but worth cleaning up when nearby.
|
`eslint-plugin-react-hooks 7.0.1` expects `eslint ^3.0.0–^9.0.0` but found `10.0.3`. Low impact but worth cleaning up when nearby.
|
||||||
|
|
||||||
- **husky + lint-staged** `[debt]`
|
|
||||||
Set up husky and lint-staged to run linting and formatting checks before every commit. Prevents CI failures from formatting or lint issues that slipped through locally.
|
|
||||||
|
|
||||||
- **OpenAPI documentation for REST endpoints** `[feature]`
|
- **OpenAPI documentation for REST endpoints** `[feature]`
|
||||||
Document the API surface using OpenAPI/Swagger. Covers all REST endpoints with request/response shapes. Useful groundwork for the admin dashboard and any future contributors.
|
Document the API surface using OpenAPI/Swagger. Covers all REST endpoints with request/response shapes. Useful groundwork for the admin dashboard and any future contributors.
|
||||||
|
|
||||||
|
|
@ -105,6 +102,7 @@ Directionally right, timing is unclear. Revisit when the next/now work is done.
|
||||||
|
|
||||||
Shipped milestones, newest first.
|
Shipped milestones, newest first.
|
||||||
|
|
||||||
|
- **04 - 2026 - husky + lint-staged + CI quality gate** - Pre-commit formatting, pre-push tests, and CI lint/typecheck/test gate before every deploy.
|
||||||
- **04 - 2026 - t00001 - Docker credential helper**
|
- **04 - 2026 - t00001 - Docker credential helper**
|
||||||
- **04 - 2026 - Pin dependencies in package.json** - Unpinned deps in a CI/CD pipeline are a real risk.
|
- **04 - 2026 - Pin dependencies in package.json** - Unpinned deps in a CI/CD pipeline are a real risk.
|
||||||
- **04 - 2026 - React error boundaries** - Catch and display runtime errors gracefully instead of crashing the entire app.
|
- **04 - 2026 - React error boundaries** - Catch and display runtime errors gracefully instead of crashing the entire app.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# 🔥 GameService Roast: `apps/api/src/services/gameService.ts`
|
# 🔥 GameService Roast: `apps/api/src/services/gameService.ts`
|
||||||
|
|
||||||
> *"It works on my machine" is not a scalability strategy.*
|
> _"It works on my machine" is not a scalability strategy._
|
||||||
|
|
||||||
**Project:** lila — Vocabulary Trainer
|
**Project:** lila — Vocabulary Trainer
|
||||||
**File Roasted:** `gameService.ts`
|
**File Roasted:** `gameService.ts`
|
||||||
|
|
@ -28,8 +28,8 @@
|
||||||
**Location:** `gameService.ts:45-58` + `InMemoryGameSessionStore.ts:update()`
|
**Location:** `gameService.ts:45-58` + `InMemoryGameSessionStore.ts:update()`
|
||||||
|
|
||||||
// Current flow (VULNERABLE):
|
// Current flow (VULNERABLE):
|
||||||
const session = await store.get(submission.sessionId); // READ
|
const session = await store.get(submission.sessionId); // READ
|
||||||
const updatedAnswers = new Map(session.answers); // MODIFY (local copy)
|
const updatedAnswers = new Map(session.answers); // MODIFY (local copy)
|
||||||
updatedAnswers.delete(submission.questionId);
|
updatedAnswers.delete(submission.questionId);
|
||||||
await store.update(submission.sessionId, { answers: updatedAnswers }); // WRITE
|
await store.update(submission.sessionId, { answers: updatedAnswers }); // WRITE
|
||||||
|
|
||||||
|
|
@ -46,43 +46,34 @@ Fix Options:
|
||||||
|
|
||||||
// Option A: Add atomic operation to store interface
|
// Option A: Add atomic operation to store interface
|
||||||
interface GameSessionStore {
|
interface GameSessionStore {
|
||||||
deleteAnswer(sessionId: string, questionId: string): Promise<boolean>;
|
deleteAnswer(sessionId: string, questionId: string): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Option B: Use Valkey Lua script for atomic read-modify-write
|
// Option B: Use Valkey Lua script for atomic read-modify-write
|
||||||
// Option C: Optimistic locking with version numbers
|
// Option C: Optimistic locking with version numbers
|
||||||
|
|
||||||
Priority: 🔴 CRITICAL — Data integrity issue
|
Priority: 🔴 CRITICAL — Data integrity issue 2. N+1 Query: Database Performance Bomb
|
||||||
2. N+1 Query: Database Performance Bomb
|
|
||||||
Location: gameService.ts:24-26 + termModel.ts:getDistractors()
|
Location: gameService.ts:24-26 + termModel.ts:getDistractors()
|
||||||
|
|
||||||
// For each of N terms, we call getDistractors():
|
// For each of N terms, we call getDistractors():
|
||||||
const questions: GameQuestion[] = await Promise.all(
|
const questions: GameQuestion[] = await Promise.all(
|
||||||
terms.map(async (term) => {
|
terms.map(async (term) => {
|
||||||
const distractorTexts = await getDistractors(term.termId, ...); // 🚩 N queries!
|
const distractorTexts = await getDistractors(term.termId, ...); // 🚩 N queries!
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
Impact Analysis:
|
Impact Analysis:
|
||||||
Rounds
|
Rounds
|
||||||
|
|
||||||
DB Queries
|
DB Queries
|
||||||
|
|
||||||
At 50 concurrent users
|
At 50 concurrent users
|
||||||
3
|
3
|
||||||
|
|
||||||
1 + 3 = 4
|
1 + 3 = 4
|
||||||
|
|
||||||
200 queries/min
|
200 queries/min
|
||||||
10
|
10
|
||||||
|
|
||||||
1 + 10 = 11
|
1 + 10 = 11
|
||||||
|
|
||||||
550 queries/min
|
550 queries/min
|
||||||
20
|
20
|
||||||
|
|
||||||
1 + 20 = 21
|
1 + 20 = 21
|
||||||
|
|
||||||
1,050 queries/min
|
1,050 queries/min
|
||||||
Each getDistractors() runs:
|
Each getDistractors() runs:
|
||||||
|
|
||||||
|
|
@ -95,15 +86,15 @@ Fix: Batch Fetch Distractors
|
||||||
|
|
||||||
// Fetch all distractors in ONE query
|
// Fetch all distractors in ONE query
|
||||||
const allDistractors = await db
|
const allDistractors = await db
|
||||||
.select({ termId: terms.id, text: translations.text })
|
.select({ termId: terms.id, text: translations.text })
|
||||||
.from(terms)
|
.from(terms)
|
||||||
.innerJoin(translations, /* ... */)
|
.innerJoin(translations, /_ ... _/)
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(terms.pos, pos),
|
eq(terms.pos, pos),
|
||||||
eq(translations.difficulty, difficulty),
|
eq(translations.difficulty, difficulty),
|
||||||
inArray(terms.id, termIds), // Batch!
|
inArray(terms.id, termIds), // Batch!
|
||||||
))
|
))
|
||||||
.limit(DISTRACTOR_FETCH_COUNT * termIds.length);
|
.limit(DISTRACTOR_FETCH_COUNT \* termIds.length);
|
||||||
|
|
||||||
// Group by termId in JS, then slice to 3 unique distractors per term
|
// Group by termId in JS, then slice to 3 unique distractors per term
|
||||||
const distractorsByTerm = groupByTermId(allDistractors);
|
const distractorsByTerm = groupByTermId(allDistractors);
|
||||||
|
|
@ -111,10 +102,10 @@ const distractorsByTerm = groupByTermId(allDistractors);
|
||||||
Priority: 🔴 CRITICAL — Performance/scalability issue
|
Priority: 🔴 CRITICAL — Performance/scalability issue
|
||||||
|
|
||||||
3. Error Handling Inconsistency
|
3. Error Handling Inconsistency
|
||||||
Location: gameService.ts:33-36
|
Location: gameService.ts:33-36
|
||||||
|
|
||||||
if (uniqueDistractors.length < 3) {
|
if (uniqueDistractors.length < 3) {
|
||||||
throw new Error(`Not enough unique distractors for term: ${term.targetText}`); // 🚩
|
throw new Error(`Not enough unique distractors for term: ${term.targetText}`); // 🚩
|
||||||
}
|
}
|
||||||
|
|
||||||
Problem: Raw Error bypasses your errorHandler middleware:
|
Problem: Raw Error bypasses your errorHandler middleware:
|
||||||
|
|
@ -127,15 +118,14 @@ Fix:
|
||||||
import { UnprocessableEntityError } from "../errors/AppError.js";
|
import { UnprocessableEntityError } from "../errors/AppError.js";
|
||||||
|
|
||||||
if (uniqueDistractors.length < 3) {
|
if (uniqueDistractors.length < 3) {
|
||||||
logger.warn({ termId: term.termId, uniqueCount: uniqueDistractors.length },
|
logger.warn({ termId: term.termId, uniqueCount: uniqueDistractors.length },
|
||||||
"insufficient_distractors");
|
"insufficient_distractors");
|
||||||
throw new UnprocessableEntityError(
|
throw new UnprocessableEntityError(
|
||||||
`Not enough unique distractors for term: ${term.targetText}`
|
`Not enough unique distractors for term: ${term.targetText}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Priority: 🟡 HIGH — Observability & UX issue
|
Priority: 🟡 HIGH — Observability & UX issue
|
||||||
⚠️ High-Severity Smells
|
⚠️ High-Severity Smells 4. Code Duplication: Singleplayer vs Multiplayer
|
||||||
4. Code Duplication: Singleplayer vs Multiplayer
|
|
||||||
Compare: gameService.ts vs multiplayerGameService.ts
|
Compare: gameService.ts vs multiplayerGameService.ts
|
||||||
// gameService.ts
|
// gameService.ts
|
||||||
const optionTexts = [term.targetText, ...uniqueDistractors.slice(0, 3)];
|
const optionTexts = [term.targetText, ...uniqueDistractors.slice(0, 3)];
|
||||||
|
|
@ -157,30 +147,29 @@ Fix: Extract pure function to @lila/shared or new @lila/game-logic:
|
||||||
|
|
||||||
// packages/shared/src/game-logic.ts
|
// packages/shared/src/game-logic.ts
|
||||||
export const buildQuestionOptions = (
|
export const buildQuestionOptions = (
|
||||||
correctAnswer: string,
|
correctAnswer: string,
|
||||||
distractors: string[],
|
distractors: string[],
|
||||||
optionCount: number = 4
|
optionCount: number = 4
|
||||||
): { options: AnswerOption[]; correctOptionId: number } => {
|
): { options: AnswerOption[]; correctOptionId: number } => {
|
||||||
const uniqueDistractors = [...new Set(distractors.filter(d => d !== correctAnswer))];
|
const uniqueDistractors = [...new Set(distractors.filter(d => d !== correctAnswer))];
|
||||||
const optionTexts = [correctAnswer, ...uniqueDistractors.slice(0, optionCount - 1)];
|
const optionTexts = [correctAnswer, ...uniqueDistractors.slice(0, optionCount - 1)];
|
||||||
const shuffled = shuffleSecure(optionTexts);
|
const shuffled = shuffleSecure(optionTexts);
|
||||||
const correctOptionId = shuffled.indexOf(correctAnswer);
|
const correctOptionId = shuffled.indexOf(correctAnswer);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
options: shuffled.map((text, idx) => ({ optionId: idx, text })),
|
options: shuffled.map((text, idx) => ({ optionId: idx, text })),
|
||||||
correctOptionId
|
correctOptionId
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
Priority: 🟡 HIGH — Maintainability issue
|
Priority: 🟡 HIGH — Maintainability issue 5. Shuffle Bias: Math.random() Trap
|
||||||
5. Shuffle Bias: Math.random() Trap
|
|
||||||
Location: utils.ts:shuffleArray() + multiplayerGameService.ts:shuffle()
|
Location: utils.ts:shuffleArray() + multiplayerGameService.ts:shuffle()
|
||||||
|
|
||||||
export const shuffleArray = <T>(array: T[]): T[] => {
|
export const shuffleArray = <T>(array: T[]): T[] => {
|
||||||
for (let i = result.length - 1; i > 0; i--) {
|
for (let i = result.length - 1; i > 0; i--) {
|
||||||
const j = Math.floor(Math.random() * (i + 1)); // 🚩 Modulo bias + non-crypto RNG
|
const j = Math.floor(Math.random() \* (i + 1)); // 🚩 Modulo bias + non-crypto RNG
|
||||||
// ...
|
// ...
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
The Math:
|
The Math:
|
||||||
|
|
@ -199,65 +188,64 @@ Fix (if needed):
|
||||||
import { randomBytes } from "crypto";
|
import { randomBytes } from "crypto";
|
||||||
|
|
||||||
const shuffleSecure = <T>(array: T[]): T[] => {
|
const shuffleSecure = <T>(array: T[]): T[] => {
|
||||||
const result = [...array];
|
const result = [...array];
|
||||||
for (let i = result.length - 1; i > 0; i--) {
|
for (let i = result.length - 1; i > 0; i--) {
|
||||||
// Use crypto.getRandomValues for better randomness
|
// Use crypto.getRandomValues for better randomness
|
||||||
const rand = randomBytes(4).readUInt32LE(0);
|
const rand = randomBytes(4).readUInt32LE(0);
|
||||||
const j = rand % (i + 1);
|
const j = rand % (i + 1);
|
||||||
[result[i], result[j]] = [result[j], result[i]];
|
[result[i], result[j]] = [result[j], result[i]];
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
Priority: 🟢 LOW — Document tradeoff and move on for now
|
Priority: 🟢 LOW — Document tradeoff and move on for now
|
||||||
|
|
||||||
6. Test Coverage Gaps
|
6. Test Coverage Gaps
|
||||||
File: gameService.test.ts
|
File: gameService.test.ts
|
||||||
✅ Well Tested:
|
✅ Well Tested:
|
||||||
|
|
||||||
Happy path: session creation, answer evaluation
|
Happy path: session creation, answer evaluation
|
||||||
Edge cases: duplicate distractors, empty results, invalid inputs
|
Edge cases: duplicate distractors, empty results, invalid inputs
|
||||||
Error propagation from DB layer
|
Error propagation from DB layer
|
||||||
|
|
||||||
❌ Missing Tests:
|
❌ Missing Tests:
|
||||||
|
|
||||||
// 1. Concurrency test (race condition)
|
// 1. Concurrency test (race condition)
|
||||||
it("rejects duplicate answers for same question under concurrent load", async () => {
|
it("rejects duplicate answers for same question under concurrent load", async () => {
|
||||||
const session = await createGameSession(validRequest, store, "user-1");
|
const session = await createGameSession(validRequest, store, "user-1");
|
||||||
const question = session.questions[0]!;
|
const question = session.questions[0]!;
|
||||||
|
|
||||||
// Submit two answers simultaneously
|
// Submit two answers simultaneously
|
||||||
const [result1, result2] = await Promise.allSettled([
|
const [result1, result2] = await Promise.allSettled([
|
||||||
evaluateAnswer({ sessionId, questionId, selectedOptionId: 0 }, store, "user-1"),
|
evaluateAnswer({ sessionId, questionId, selectedOptionId: 0 }, store, "user-1"),
|
||||||
evaluateAnswer({ sessionId, questionId, selectedOptionId: 1 }, store, "user-1"),
|
evaluateAnswer({ sessionId, questionId, selectedOptionId: 1 }, store, "user-1"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Exactly one should succeed, one should throw ConflictError
|
// Exactly one should succeed, one should throw ConflictError
|
||||||
expect([result1, result2].filter(r => r.status === "fulfilled")).toHaveLength(1);
|
expect([result1, result2].filter(r => r.status === "fulfilled")).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. TTL expiration test
|
// 2. TTL expiration test
|
||||||
it("deletes session after TTL expires", async () => {
|
it("deletes session after TTL expires", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const session = await createGameSession(validRequest, store, "user-1");
|
const session = await createGameSession(validRequest, store, "user-1");
|
||||||
|
|
||||||
vi.advanceTimersByTime(31 * 60 * 1000); // 31 minutes
|
vi.advanceTimersByTime(31 _ 60 _ 1000); // 31 minutes
|
||||||
|
|
||||||
await expect(store.get(session.sessionId)).resolves.toBeNull();
|
await expect(store.get(session.sessionId)).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Distractor fallback strategy test
|
// 3. Distractor fallback strategy test
|
||||||
it("uses fallback when <3 unique distractors available", async () => {
|
it("uses fallback when <3 unique distractors available", async () => {
|
||||||
mockGetDistractors.mockResolvedValue(["same", "same", "same", "same"]);
|
mockGetDistractors.mockResolvedValue(["same", "same", "same", "same"]);
|
||||||
// Should either: (a) fetch from broader pool, or (b) reduce rounds gracefully
|
// Should either: (a) fetch from broader pool, or (b) reduce rounds gracefully
|
||||||
});
|
});
|
||||||
|
|
||||||
Priority: 🟡 HIGH — Prevents regression on critical fixes
|
Priority: 🟡 HIGH — Prevents regression on critical fixes
|
||||||
🧼 Code Quality Nitpicks
|
🧼 Code Quality Nitpicks 7. Magic Numbers
|
||||||
7. Magic Numbers
|
|
||||||
|
|
||||||
// gameService.ts:52
|
// gameService.ts:52
|
||||||
await store.create(sessionId, {...}, 30 * 60 * 1000); // What is this?
|
await store.create(sessionId, {...}, 30 _ 60 _ 1000); // What is this?
|
||||||
|
|
||||||
// termModel.ts:65
|
// termModel.ts:65
|
||||||
.limit(count); // count=6, but why?
|
.limit(count); // count=6, but why?
|
||||||
|
|
@ -267,16 +255,16 @@ optionId: z.number().int().min(0).max(3), // Why 4 options?
|
||||||
|
|
||||||
Fix: Centralize in @lila/shared/constants.ts:
|
Fix: Centralize in @lila/shared/constants.ts:
|
||||||
|
|
||||||
export const GAME_SESSION_TTL_MS = 30 * 60 * 1000;
|
export const GAME*SESSION_TTL_MS = 30 * 60 \_ 1000;
|
||||||
export const DISTRACTOR_FETCH_COUNT = 6;
|
export const DISTRACTOR_FETCH_COUNT = 6;
|
||||||
export const GAME_OPTION_COUNT = 4;
|
export const GAME_OPTION_COUNT = 4;
|
||||||
export const MIN_UNIQUE_DISTRACTORS = 3;
|
export const MIN_UNIQUE_DISTRACTORS = 3;
|
||||||
|
|
||||||
8. Mutable Reference Leakage
|
8. Mutable Reference Leakage
|
||||||
Location: InMemoryGameSessionStore.ts:get()
|
Location: InMemoryGameSessionStore.ts:get()
|
||||||
|
|
||||||
get(sessionId: string): Promise<GameSessionData | null> {
|
get(sessionId: string): Promise<GameSessionData | null> {
|
||||||
return Promise.resolve(entry.data); // 🚩 Returns mutable reference to internal state
|
return Promise.resolve(entry.data); // 🚩 Returns mutable reference to internal state
|
||||||
}
|
}
|
||||||
|
|
||||||
Risk: Any code that does session.answers.delete(...) mutates the store's internal Map directly.
|
Risk: Any code that does session.answers.delete(...) mutates the store's internal Map directly.
|
||||||
|
|
@ -291,37 +279,35 @@ return Promise.resolve(entry.data as Readonly<GameSessionData>);
|
||||||
// Option C: Use immutable data structures (overkill for now)
|
// Option C: Use immutable data structures (overkill for now)
|
||||||
|
|
||||||
9. Zero Observability
|
9. Zero Observability
|
||||||
Problem: No logging, no metrics. You're flying blind in production.
|
Problem: No logging, no metrics. You're flying blind in production.
|
||||||
Minimal Fix (5 minutes):
|
Minimal Fix (5 minutes):
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// apps/api/src/lib/logger.ts
|
// apps/api/src/lib/logger.ts
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
export const logger = pino({
|
export const logger = pino({
|
||||||
level: process.env.LOG_LEVEL || "info",
|
level: process.env.LOG_LEVEL || "info",
|
||||||
transport: process.env.NODE_ENV === "production"
|
transport: process.env.NODE_ENV === "production"
|
||||||
? { target: "pino-pretty" }
|
? { target: "pino-pretty" }
|
||||||
: undefined
|
: undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
// In gameService.ts:
|
// In gameService.ts:
|
||||||
import { logger } from "../lib/logger.js";
|
import { logger } from "../lib/logger.js";
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{ userId, sourceLang, targetLang, termCount: terms.length },
|
{ userId, sourceLang, targetLang, termCount: terms.length },
|
||||||
"game_session_created"
|
"game_session_created"
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
{ sessionId, questionId, isCorrect, responseTimeMs },
|
{ sessionId, questionId, isCorrect, responseTimeMs },
|
||||||
"answer_evaluated"
|
"answer_evaluated"
|
||||||
);
|
);
|
||||||
|
|
||||||
Bonus: Export a Prometheus histogram for game_service_duration_seconds.
|
Bonus: Export a Prometheus histogram for game_service_duration_seconds.
|
||||||
|
|
||||||
10. ORDER BY RANDOM() Time Bomb
|
10. ORDER BY RANDOM() Time Bomb
|
||||||
Location: termModel.ts:getGameTerms() + getDistractors()
|
Location: termModel.ts:getGameTerms() + getDistractors()
|
||||||
|
|
||||||
.orderBy(sql`RANDOM()`) // 🚩 Fine for 10k rows, slow for 1M
|
.orderBy(sql`RANDOM()`) // 🚩 Fine for 10k rows, slow for 1M
|
||||||
|
|
||||||
|
|
@ -343,6 +329,6 @@ WHERE ...
|
||||||
LIMIT $1
|
LIMIT $1
|
||||||
|
|
||||||
-- Option C: Random offset (simple, but still scans)
|
-- Option C: Random offset (simple, but still scans)
|
||||||
OFFSET floor(random() * (SELECT count(*) FROM terms WHERE ...))
|
OFFSET floor(random() _ (SELECT count(_) FROM terms WHERE ...))
|
||||||
|
|
||||||
Action: Add a ticket to documentation/tickets/t00009.md now.
|
Action: Add a ticket to documentation/tickets/t00009.md now.
|
||||||
|
|
@ -84,7 +84,10 @@ Rejected because: for user-owned resources identified by opaque IDs, confirming
|
||||||
2. `GameSessionStore.ts` — add `userId` to `GameSessionData`:
|
2. `GameSessionStore.ts` — add `userId` to `GameSessionData`:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
export type GameSessionData = { answers: Map<string, number>; userId: string };
|
export type GameSessionData = {
|
||||||
|
answers: Map<string, number>;
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
3. `gameService.ts` — add `userId` to both function signatures:
|
3. `gameService.ts` — add `userId` to both function signatures:
|
||||||
|
|
@ -100,7 +103,11 @@ Rejected because: for user-owned resources identified by opaque IDs, confirming
|
||||||
Store it on create:
|
Store it on create:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
await store.create(sessionId, { answers: answerKey, userId }, 30 * 60 * 1000);
|
await store.create(
|
||||||
|
sessionId,
|
||||||
|
{ answers: answerKey, userId },
|
||||||
|
30 * 60 * 1000,
|
||||||
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
Assert on evaluate:
|
Assert on evaluate:
|
||||||
|
|
@ -114,7 +121,7 @@ Rejected because: for user-owned resources identified by opaque IDs, confirming
|
||||||
4. `gameController.ts` — extract from authenticated request:
|
4. `gameController.ts` — extract from authenticated request:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
req.session.user.id
|
req.session.user.id;
|
||||||
```
|
```
|
||||||
|
|
||||||
5. `gameRouter.ts` — cast at registration:
|
5. `gameRouter.ts` — cast at registration:
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,9 @@ Not chosen for this ticket — the database query is in `@lila/db` and is a sepa
|
||||||
- Filter distractors against the correct answer before building options:
|
- Filter distractors against the correct answer before building options:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const uniqueDistractors = distractorTexts.filter((t) => t !== term.targetText);
|
const uniqueDistractors = distractorTexts.filter(
|
||||||
|
(t) => t !== term.targetText,
|
||||||
|
);
|
||||||
const optionTexts = [term.targetText, ...uniqueDistractors.slice(0, 3)];
|
const optionTexts = [term.targetText, ...uniqueDistractors.slice(0, 3)];
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,13 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "pnpm --filter @lila/shared build && pnpm --filter @lila/db build && pnpm --filter @lila/api build",
|
"build": "pnpm --filter @lila/shared build && pnpm --filter @lila/db build && pnpm --filter @lila/api build",
|
||||||
"dev": "concurrently --names \"api,web\" -c \"magenta.bold,green.bold\" \"pnpm --filter @lila/api dev\" \"pnpm --filter @lila/web dev\"",
|
"dev": "concurrently --names \"api,web\" -c \"magenta.bold,green.bold\" \"pnpm --filter @lila/api dev\" \"pnpm --filter @lila/web dev\"",
|
||||||
"prepare": "husky",
|
"prepare": "husky || true",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:run": "vitest run",
|
"test:run": "vitest run",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"typecheck": "tsc --build --noEmit"
|
"typecheck": "pnpm -r typecheck"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"**/*.{ts,tsx}": [
|
"**/*.{ts,tsx}": [
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "rm -rf dist && tsc",
|
"build": "rm -rf dist && tsc",
|
||||||
"generate": "drizzle-kit generate",
|
"generate": "drizzle-kit generate",
|
||||||
"migrate": "drizzle-kit migrate"
|
"migrate": "drizzle-kit migrate",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lila/shared": "workspace:*",
|
"@lila/shared": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc"
|
"build": "tsc",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./dist/src/index.js"
|
".": "./dist/src/index.js"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue