From f067440f64cfbe5b19af91742f560859c6aae854 Mon Sep 17 00:00:00 2001 From: chithuchcmus Date: Sun, 5 Jul 2026 11:05:19 +0700 Subject: [PATCH 1/2] feat - (problem4): add sum_to_n TypeScript solution with tests - (problem5): add product CRUD REST API with Express, Docker, and tests - (problem6): add system design documentation --- .gitignore | 63 ++ GUIDELINE.md | 212 ++++ readme.md => README.md | 0 src/problem4/README.md | 43 + src/problem4/sum_to_n.test.ts | 119 ++ src/problem4/sum_to_n.ts | 53 + src/problem5/.dockerignore | 16 + src/problem5/.env.example | 13 + src/problem5/.gitignore | 35 + src/problem5/Dockerfile | 30 + src/problem5/README.md | 453 ++++++++ .../controller/product-controller.test.ts | 252 +++++ .../__tests__/service/product-service.test.ts | 136 +++ src/problem5/config.ts | 65 ++ src/problem5/container.ts | 76 ++ src/problem5/controller/product-controller.ts | 44 + src/problem5/docker-compose.yml | 45 + src/problem5/dto/mapper.ts | 17 + src/problem5/dto/product-dto.ts | 14 + src/problem5/index.ts | 82 ++ src/problem5/logger.ts | 6 + src/problem5/middleware/async-handler.ts | 9 + src/problem5/middleware/error-handler.ts | 37 + src/problem5/middleware/request-context.ts | 14 + src/problem5/middleware/validate.ts | 49 + src/problem5/model/errors.ts | 31 + src/problem5/model/product.ts | 47 + src/problem5/package.json | 28 + src/problem5/repository/database.ts | 165 +++ src/problem5/repository/db-errors.ts | 11 + src/problem5/repository/interfaces.ts | 13 + src/problem5/repository/product-repository.ts | 228 ++++ src/problem5/router.ts | 36 + src/problem5/schema/product.ts | 39 + src/problem5/service/interfaces.ts | 9 + src/problem5/service/product-service.ts | 63 ++ src/problem5/tsconfig.json | 29 + src/problem5/types/branded.ts | 10 + src/problem5/types/express.d.ts | 7 + src/problem5/types/index.ts | 1 + src/problem5/vitest.config.ts | 11 + src/problem6/docs/SEQUENCES.md | 327 ++++++ src/problem6/docs/SERVICES.md | 78 ++ src/problem6/docs/SYSTEM_DESIGN.md | 1006 +++++++++++++++++ src/problem6/readme.md | 47 + 45 files changed, 4069 insertions(+) create mode 100644 .gitignore create mode 100644 GUIDELINE.md rename readme.md => README.md (100%) create mode 100644 src/problem4/README.md create mode 100644 src/problem4/sum_to_n.test.ts create mode 100644 src/problem4/sum_to_n.ts create mode 100644 src/problem5/.dockerignore create mode 100644 src/problem5/.env.example create mode 100644 src/problem5/.gitignore create mode 100644 src/problem5/Dockerfile create mode 100644 src/problem5/README.md create mode 100644 src/problem5/__tests__/controller/product-controller.test.ts create mode 100644 src/problem5/__tests__/service/product-service.test.ts create mode 100644 src/problem5/config.ts create mode 100644 src/problem5/container.ts create mode 100644 src/problem5/controller/product-controller.ts create mode 100644 src/problem5/docker-compose.yml create mode 100644 src/problem5/dto/mapper.ts create mode 100644 src/problem5/dto/product-dto.ts create mode 100644 src/problem5/index.ts create mode 100644 src/problem5/logger.ts create mode 100644 src/problem5/middleware/async-handler.ts create mode 100644 src/problem5/middleware/error-handler.ts create mode 100644 src/problem5/middleware/request-context.ts create mode 100644 src/problem5/middleware/validate.ts create mode 100644 src/problem5/model/errors.ts create mode 100644 src/problem5/model/product.ts create mode 100644 src/problem5/package.json create mode 100644 src/problem5/repository/database.ts create mode 100644 src/problem5/repository/db-errors.ts create mode 100644 src/problem5/repository/interfaces.ts create mode 100644 src/problem5/repository/product-repository.ts create mode 100644 src/problem5/router.ts create mode 100644 src/problem5/schema/product.ts create mode 100644 src/problem5/service/interfaces.ts create mode 100644 src/problem5/service/product-service.ts create mode 100644 src/problem5/tsconfig.json create mode 100644 src/problem5/types/branded.ts create mode 100644 src/problem5/types/express.d.ts create mode 100644 src/problem5/types/index.ts create mode 100644 src/problem5/vitest.config.ts create mode 100644 src/problem6/docs/SEQUENCES.md create mode 100644 src/problem6/docs/SERVICES.md create mode 100644 src/problem6/docs/SYSTEM_DESIGN.md create mode 100644 src/problem6/readme.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..e498c27174 --- /dev/null +++ b/.gitignore @@ -0,0 +1,63 @@ +# Dependencies +**/node_modules/ +**/package-lock.json +**/yarn.lock +**/pnpm-lock.yaml + +# Build outputs +**/dist/ +**/build/ +**/out/ +**/.next/ +**/.nuxt/ +*.log + +# Environment variables +**/.env +**/.env.local +**/.env.*.local + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Testing +**/coverage/ +*.lcov + +# Logs +**/logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids/ +*.pid +*.seed +*.pid.lock + +# TypeScript +**/*.tsbuildinfo +**/tsconfig.tsbuildinfo + +# Optional npm cache directory +**/.npm + +# Optional eslint cache +**/.eslintcache + +# Misc +**/.turbo/ +**/.cache/ +**/.parcel-cache/ +**/.webpack/ +**/typings/ diff --git a/GUIDELINE.md b/GUIDELINE.md new file mode 100644 index 0000000000..be3cae2d69 --- /dev/null +++ b/GUIDELINE.md @@ -0,0 +1,212 @@ +# 99Tech Code Challenge — Backend Guideline + +## 1. Context + +- **Challenge:** 99Tech Code Challenge #1 +- **Role:** Backend Engineer +- **Repository:** `99techteam/code-challenge` +- **Goal:** Implement solutions for 5 problems under `src/problem1/` through `src/problem5/` +- **Evaluation:** Correctness, code quality, architecture, TypeScript usage, error handling, test coverage, and creativity where constraints are open + +## 2. Tech Stack + +| Layer | Choice | +| ------------------ | ----------------------- | +| Runtime | Node.js (LTS) | +| Language | TypeScript (strict) | +| Framework | Express.js | +| Validation | Zod | +| Testing | Vitest + Supertest | +| Linting/Formatting | ESLint + Prettier | +| DB (if needed) | SQLite via better-sqlite3 (no external deps) | +| Logging | pino | + +## 3. Project Structure + +``` +code-challenge/ +├── src/ +│ ├── problem1/ # Independent problem +│ │ ├── index.ts # Router + public API +│ │ ├── controller/ # HTTP layer +│ │ ├── service/ # Business logic +│ │ ├── repository/ # Data access +│ │ ├── model/ # Domain types/interfaces +│ │ ├── middleware/ # Problem-specific middleware +│ │ ├── schema/ # Zod validation schemas +│ │ └── __tests__/ # Tests +│ │ +│ ├── problem2/ # Independent problem +│ ├── problem3/ # Independent problem +│ ├── problem4/ # Independent problem +│ └── problem5/ # Independent problem +│ +├── app.ts # Express app factory (for testing) +├── server.ts # Entry point — starts the server +├── package.json +├── tsconfig.json +├── vitest.config.ts +├── eslint.config.js +└── .prettierrc +``` + +## 4. Architecture Principles + +### 4.1 Layered Architecture (per problem) + +``` +Request → Router → Controller → Service → Repository → Data + ↓ ↓ + Validation Business Logic + (Zod) (Domain rules) +``` + +**Rules:** +- Controller: receives HTTP request, validates input, calls service, formats response +- Service: pure business logic, no HTTP awareness, no Express types +- Repository: data access only, no business logic +- No layer may skip or reverse-call another layer +- Each problem is **independent** — no imports between problems +- Each problem should have its own error handling, middleware, and utilities + +### 4.2 Error Handling + +- Custom `AppError` base class with `statusCode`, `message`, `code` +- Specific subclasses: `ValidationError`, `NotFoundError`, `ConflictError` +- Each problem has its own error middleware in `problem{n}/middleware/error-handler.ts` +- Errors thrown in services propagate up to the error middleware + +### 4.3 Response Format + +```typescript +// Success +{ "data": T, "meta"?: PaginationInfo } + +// Error (handled by error middleware) +{ "error": { "code": string, "message": string, "details"?: unknown } } +``` + +## 5. Coding Conventions + +### TypeScript +- **Strict mode** — `strict: true` in tsconfig +- **No `any`** — use `unknown` + type guards when type is uncertain +- **Explicit return types** on all exported functions +- **Readonly where possible** — `readonly` properties, prefer `const` +- **Named exports** over default exports +- **Interface** for object shapes, **type** for unions/intersections/utilities + +### Style +- Async/await — no raw `.then()` chains +- Descriptive names — `getUserById` not `getUser` +- Single responsibility per function (keep functions small) +- No `console.log` — use `pino` logger +- No unused imports or variables + +### Testing +- File naming: `*.test.ts` +- Unit tests for services (mock repositories) +- Integration tests for controllers (Supertest against Express app) +- Cover: happy path, edge cases, error paths +- Test naming: `describe('ServiceName')` → `it('should ...')` + +## 6. Dependency Flow + +Each problem is **completely independent** — no dependencies between problems. + +``` +problem1 (standalone) +problem2 (standalone) +problem3 (standalone) +problem4 (standalone) +problem5 (standalone) +``` + +**What this means:** +- Each problem has its own domain models, services, and utilities +- No problem imports from any other problem +- Each problem is self-contained with its own error handling, middleware, and types + +## 7. Per-Problem Workflow + +For each problem, the implementation order is: + +1. **Read & understand** the problem statement +2. **Define domain models** — types/interfaces in `model/` +3. **Define validation schemas** — Zod in `schema/` +4. **Implement repository** — data access layer +5. **Implement service** — business logic with unit tests +6. **Implement controller** — HTTP layer with integration tests +7. **Wire router** in `index.ts` +8. **Register** in `app.ts` +9. **Verify** — run lint + typecheck + tests + +## 8. Evaluation Criteria (what reviewers score on) + +| Criteria | What they look for | +| ------------------------- | ----------------------------------------------------- | +| Correctness | Does the solution produce the right output? | +| Architecture | Clean separation of concerns, layered design | +| TypeScript quality | Type safety, no `any`, proper generics | +| Error handling | Edge cases handled, meaningful error messages | +| Test coverage | Happy path + error paths tested | +| Code readability | Clean naming, small functions, well-organized | +| Creativity | Thoughtful design where requirements are open-ended | +| Documentation | README per problem explaining design decisions | + +## 9. Maximizing Evaluation Score + +### 9.1 General Principles +- **Read requirements thoroughly** — implement exactly what's asked, nothing more, nothing less +- **Follow the framework** — use the layered architecture, tech stack, and conventions defined in this document +- **Prioritize correctness** — working code beats clever code +- **Test everything** — happy paths, edge cases, error scenarios +- **Document decisions** — explain trade-offs and design choices in per-problem READMEs + +### 9.2 Problem-Type Strategy + +**Coding Problems** (algorithmic/data structure focused) +- Optimize for time/space complexity as specified +- Handle edge cases explicitly (empty input, null, boundary values) +- Write clear, readable code with meaningful variable names +- Include unit tests covering all scenarios +- Add comments only for non-obvious logic + +**Function/Feature Problems** (API endpoints, business logic) +- Strict input validation with Zod schemas +- Proper HTTP status codes (200, 201, 400, 404, 409, 500) +- Consistent response format (`{ data, meta }` / `{ error }`) +- Integration tests with Supertest +- Error handling for all failure paths + +**Architecture Problems** (system design, refactoring) +- Clear separation of concerns (controller/service/repository) +- Dependency injection where appropriate +- Extensibility and maintainability +- Document architectural decisions +- Show trade-offs considered + +**Documentation Problems** (design docs, technical writing) +- Clear structure with headings, diagrams, examples +- Cover: requirements, design decisions, trade-offs, future improvements +- Use diagrams (Mermaid/ASCII) for architecture/flows +- Keep it concise but comprehensive + +### 9.3 Quality Checklist Before Submission +- [ ] All requirements implemented and tested +- [ ] Lint passes with zero warnings +- [ ] TypeScript strict mode, no `any` types +- [ ] Test coverage: happy path + error paths +- [ ] README explains design decisions +- [ ] No hardcoded secrets or credentials +- [ ] Code follows project conventions + +## 10. Assumptions & Notes + +- Problem statements will be provided per-problem in future sessions +- Problems are **independent** — each is self-contained with no dependencies on other problems +- Each problem should have its own error handling, middleware, and utilities (no shared `common/` directory) +- In-memory data storage is acceptable unless a problem specifies otherwise +- SQLite is available if persistence is needed (no external DB setup required) +- The `app.ts` exports the Express app (without listening) for testability +- The `server.ts` imports `app.ts` and starts the HTTP listener diff --git a/readme.md b/README.md similarity index 100% rename from readme.md rename to README.md diff --git a/src/problem4/README.md b/src/problem4/README.md new file mode 100644 index 0000000000..eb04e039dc --- /dev/null +++ b/src/problem4/README.md @@ -0,0 +1,43 @@ +# Task + +Provide 3 unique implementations of the following function in TypeScript. + +- Comment on the complexity or efficiency of each function. + +**Input**: `n` - any integer + +*Assuming this input will always produce a result lesser than `Number.MAX_SAFE_INTEGER`*. + +**Output**: `return` - summation to `n`, i.e. `sum_to_n(5) === 1 + 2 + 3 + 4 + 5 === 15`. + +## Solution + +Three implementations provided in `sum_to_n.ts`: + +| Function | Approach | Time Complexity | Space Complexity | +|----------|----------|-----------------|------------------| +| `sumIterative` | For loop accumulation | O(n) | O(1) | +| `sumRecursive` | Recursive calls | O(n) | O(n) | +| `sumFormula` | Gauss's formula `n*(n+1)/2` | O(1) | O(1) | + +## Design Decisions + +- **All integers supported**: Positive, zero, and negative values handled correctly +- **Named exports**: Following TypeScript conventions over default exports +- **Descriptive naming**: `sumIterative`, `sumRecursive`, `sumFormula` for clarity +- **JSDoc comments**: Document time/space complexity for each function + +## Trade-offs + +- **Iterative**: Simple and memory-efficient, but requires O(n) time +- **Recursive**: Elegant but uses O(n) stack space; risks stack overflow for large n +- **Formula**: Optimal O(1) performance, but requires separate logic for negative numbers + +## Testing + +21 test cases in `sum_to_n.test.ts` covering: +- Positive numbers (1, 5, 100) +- Zero +- Negative numbers (-1, -2, -3) +- Large numbers + diff --git a/src/problem4/sum_to_n.test.ts b/src/problem4/sum_to_n.test.ts new file mode 100644 index 0000000000..042a33a159 --- /dev/null +++ b/src/problem4/sum_to_n.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from "vitest"; +import { sumIterative, sumRecursive, sumFormula } from "./sum_to_n"; + +// Tests for iterative approach - handles large numbers but O(n) time +describe("sumIterative", () => { + it("should return 15 for n = 5", () => { + expect(sumIterative(5)).toBe(15); + }); + + it("should return 1 for n = 1", () => { + expect(sumIterative(1)).toBe(1); + }); + + it("should return 0 for n = 0", () => { + expect(sumIterative(0)).toBe(0); + }); + + it("should return 0 for n = -1", () => { + expect(sumIterative(-1)).toBe(0); + }); + + it("should return -2 for n = -2", () => { + expect(sumIterative(-2)).toBe(-2); + }); + + it("should return -5 for n = -3", () => { + expect(sumIterative(-3)).toBe(-5); + }); + + it("should return 5050 for n = 100", () => { + expect(sumIterative(100)).toBe(5050); + }); + + it("should handle large numbers without overflow", () => { + expect(sumIterative(100000)).toBe(5000050000); + }); + + it("should handle large negative numbers", () => { + expect(sumIterative(-100000)).toBe(-5000049999); + }); +}); + +// Tests for recursive approach - uses trampoline to avoid stack overflow +describe("sumRecursive", () => { + it("should return 15 for n = 5", () => { + expect(sumRecursive(5)).toBe(15); + }); + + it("should return 1 for n = 1", () => { + expect(sumRecursive(1)).toBe(1); + }); + + it("should return 0 for n = 0", () => { + expect(sumRecursive(0)).toBe(0); + }); + + it("should return 0 for n = -1", () => { + expect(sumRecursive(-1)).toBe(0); + }); + + it("should return -2 for n = -2", () => { + expect(sumRecursive(-2)).toBe(-2); + }); + + it("should return -5 for n = -3", () => { + expect(sumRecursive(-3)).toBe(-5); + }); + + it("should return 5050 for n = 100", () => { + expect(sumRecursive(100)).toBe(5050); + }); + + it("should handle large numbers without stack overflow", () => { + expect(sumRecursive(100000)).toBe(5000050000); + }); + + it("should handle large negative numbers without stack overflow", () => { + expect(sumRecursive(-100000)).toBe(-5000049999); + }); +}); + +// Tests for formula approach - uses BigInt for intermediate overflow protection +describe("sumFormula", () => { + it("should return 15 for n = 5", () => { + expect(sumFormula(5)).toBe(15); + }); + + it("should return 1 for n = 1", () => { + expect(sumFormula(1)).toBe(1); + }); + + it("should return 0 for n = 0", () => { + expect(sumFormula(0)).toBe(0); + }); + + it("should return 0 for n = -1", () => { + expect(sumFormula(-1)).toBe(0); + }); + + it("should return -2 for n = -2", () => { + expect(sumFormula(-2)).toBe(-2); + }); + + it("should return -5 for n = -3", () => { + expect(sumFormula(-3)).toBe(-5); + }); + + it("should return 5050 for n = 100", () => { + expect(sumFormula(100)).toBe(5050); + }); + + it("should handle large numbers with BigInt precision", () => { + expect(sumFormula(100000000)).toBe(5000000050000000); + }); + + it("should handle large negative numbers with BigInt precision", () => { + expect(sumFormula(-100000000)).toBe(-5000000049999999); + }); +}); diff --git a/src/problem4/sum_to_n.ts b/src/problem4/sum_to_n.ts new file mode 100644 index 0000000000..f86d413543 --- /dev/null +++ b/src/problem4/sum_to_n.ts @@ -0,0 +1,53 @@ +/** + * Iterative approach using a for loop. + * Time Complexity: O(n) — iterates from 1 to n (or 1 down to n for negative) + * Space Complexity: O(1) — only uses a single accumulator variable + */ +export function sumIterative(n: number): number { + let sum = 0; + if (n >= 1) { + for (let i = 1; i <= n; i++) sum += i; + } else if (n <= -1) { + for (let i = 1; i >= n; i--) sum += i; + } + return sum; +} + +type Thunk = T | (() => Thunk); + +function trampoline(fn: () => Thunk): T { + let result: Thunk = fn(); + while (typeof result === "function") { + result = (result as () => Thunk)(); + } + return result; +} + +function sumRecHelper(n: number, acc: number): Thunk { + if (n === 0) return acc; + if (n > 0) return () => sumRecHelper(n - 1, acc + n); + if (n === -1) return acc; + return () => sumRecHelper(n + 1, acc + n); +} + +/** + * Recursive approach with trampoline to avoid stack overflow. + * Time Complexity: O(|n|) — makes |n| recursive calls + * Space Complexity: O(1) — trampoline converts recursion to iteration + */ +export function sumRecursive(n: number): number { + return trampoline(() => sumRecHelper(n, 0)); +} + +/** + * Mathematical formula approach (Gauss's formula) using BigInt for precision. + * Time Complexity: O(1) — constant time computation + * Space Complexity: O(1) — no additional memory used + */ +export function sumFormula(n: number): number { + const bigN = BigInt(n); + if (n >= 0) { + return Number((bigN * (bigN + 1n)) / 2n); + } + return Number(1n + ((1n - bigN) * bigN) / 2n); +} diff --git a/src/problem5/.dockerignore b/src/problem5/.dockerignore new file mode 100644 index 0000000000..6572bb338b --- /dev/null +++ b/src/problem5/.dockerignore @@ -0,0 +1,16 @@ +node_modules +dist +.env +.env.* +.git +.gitignore +.dockerignore +docker-compose.yml +README.md +DECISIONS.md +__tests__ +coverage +.vscode +.idea +*.md +*.log diff --git a/src/problem5/.env.example b/src/problem5/.env.example new file mode 100644 index 0000000000..802cccb48e --- /dev/null +++ b/src/problem5/.env.example @@ -0,0 +1,13 @@ +PORT=3000 +NODE_ENV=development +LOG_LEVEL=info +SHUTDOWN_TIMEOUT_MS=10000 + +DB_HOST=localhost +DB_PORT=5432 +DB_USER=postgres +DB_PASSWORD=postgres +DB_NAME=products_db +DB_MAX_CONNECTIONS=10 +DB_CONNECTION_TIMEOUT_MS=5000 +DB_QUERY_TIMEOUT_MS=10000 diff --git a/src/problem5/.gitignore b/src/problem5/.gitignore new file mode 100644 index 0000000000..f3691f9e03 --- /dev/null +++ b/src/problem5/.gitignore @@ -0,0 +1,35 @@ +# Dependencies +node_modules/ + +# Build outputs +dist/ + +# Environment variables +.env +.env.local +.env.*.local + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Testing +coverage/ +*.lcov + +# Logs +*.log +npm-debug.log* + +# TypeScript +*.tsbuildinfo + +# Docker volumes +postgres_data/ diff --git a/src/problem5/Dockerfile b/src/problem5/Dockerfile new file mode 100644 index 0000000000..d053b1a51e --- /dev/null +++ b/src/problem5/Dockerfile @@ -0,0 +1,30 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY src/problem5/package*.json ./ +RUN npm ci + +COPY src/problem5/ . +RUN npm run build + +FROM node:20-alpine + +WORKDIR /app + +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nodejs -u 1001 + +COPY src/problem5/package*.json ./ +RUN npm ci --only=production && npm cache clean --force + +COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist + +USER nodejs + +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3000/health/liveness', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" + +CMD ["node", "dist/index.js"] diff --git a/src/problem5/README.md b/src/problem5/README.md new file mode 100644 index 0000000000..43cca1e8f8 --- /dev/null +++ b/src/problem5/README.md @@ -0,0 +1,453 @@ +# Problem 5: Product CRUD API + +A RESTful API for managing products, built with **ExpressJS**, **TypeScript**, and **PostgreSQL**. + +## Overview + +This is a production-ready Product CRUD API following ExpressJS best practices with layered architecture. The API supports creating, listing (with filters), retrieving, updating, and deleting products with soft delete functionality. + +**Tech Stack:** +- **Framework:** Express.js +- **Language:** TypeScript (strict mode) +- **Validation:** Zod +- **Database:** PostgreSQL +- **Logging:** pino +- **Testing:** Vitest + Supertest +- **Containerization:** Docker + Docker Compose + +## Prerequisites + +- Node.js LTS (v18 or higher) +- Docker & Docker Compose (recommended) +- OR PostgreSQL 16+ (for local development without Docker) + +## Quick Start with Docker + +```bash +# Copy environment variables +cp .env.example .env + +# Start PostgreSQL and the app +docker-compose up -d + +# View logs +docker-compose logs -f app + +# Stop everything +docker-compose down +``` + +The API will be available at `http://localhost:3000/v1/products` + +## Local Development (without Docker) + +```bash +# Install dependencies +npm install + +# Set up PostgreSQL (ensure it's running) +createdb products_db + +# Copy and configure environment variables +cp .env.example .env +# Edit .env with your PostgreSQL credentials + +# Development (with hot reload) +npm run dev + +# Run tests +npm test + +# Build (type check + compile) +npm run build +``` + +## Configuration + +| Variable | Default | Description | +|---|---|---| +| `PORT` | `3000` | Server port | +| `DB_HOST` | `localhost` | PostgreSQL host | +| `DB_PORT` | `5432` | PostgreSQL port | +| `DB_USER` | `postgres` | PostgreSQL user | +| `DB_PASSWORD` | `postgres` | PostgreSQL password | +| `DB_NAME` | `products_db` | PostgreSQL database name | +| `DB_MAX_CONNECTIONS` | `10` | Max pool connections | +| `DB_CONNECTION_TIMEOUT_MS` | `5000` | Connection timeout in milliseconds | +| `DB_QUERY_TIMEOUT_MS` | `10000` | Query timeout in milliseconds | +| `NODE_ENV` | `development` | Environment (development/production/test) | +| `LOG_LEVEL` | `info` | Pino log level (debug/info/warn/error) | +| `SHUTDOWN_TIMEOUT_MS` | `10000` | Graceful shutdown timeout in milliseconds | + +## API Documentation + +Base URL: `http://localhost:3000/v1/products` + +### Health Check + +```bash +curl http://localhost:3000/health/liveness +curl http://localhost:3000/health/readiness +``` + +**Response (200 OK):** +```json +{ "status": "ok" } +``` + +### Create Product + +```bash +curl -X POST http://localhost:3000/v1/products \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Widget", + "description": "A useful widget", + "price": 29.99, + "category": "gadgets", + "sku": "WDG-003", + "isActive": true, + "metadata": {"color": "red"} + }' +``` + +**Response (201 Created):** +```json +{ + "code": "success", + "data": { + "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV", + "name": "Widget", + "description": "A useful widget", + "price": 29.99, + "category": "gadgets", + "sku": "WDG-003", + "isActive": true + } +} +``` + +### List Products + +```bash +curl "http://localhost:3000/v1/products?category=gadgets&minPrice=10&maxPrice=50&search=widget&page=1&limit=20" +``` + +**Query Parameters:** +- `category` (optional): Filter by exact category match +- `minPrice` (optional): Filter products with price >= minPrice +- `maxPrice` (optional): Filter products with price <= maxPrice +- `search` (optional): Partial match on product name (case-insensitive) +- `page` (optional, default: 1): Page number +- `limit` (optional, default: 20, max: 100): Items per page + +**Response (200 OK):** +```json +{ + "code": "success", + "data": [ + { + "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV", + "name": "Widget", + "description": "A useful widget", + "price": 29.99, + "category": "gadgets", + "sku": "WDG-001", + "isActive": true + } + ], + "meta": { + "total": 1, + "page": 1, + "limit": 20 + } +} +``` + +### Get Product by ID + +```bash +curl http://localhost:3000/v1/products/01ARZ3NDEKTSV4RRFFQ69G5FAV +``` + +**Response (200 OK):** +```json +{ + "code": "success", + "data": { + "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV", + "name": "Widget", + "description": "A useful widget", + "price": 29.99, + "category": "gadgets", + "sku": "WDG-001", + "isActive": true + } +} +``` + +### Update Product (Partial) + +```bash +curl -X PATCH http://localhost:3000/v1/products/01ARZ3NDEKTSV4RRFFQ69G5FAV \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Updated Widget", + "price": 39.99 + }' +``` + +**Response (200 OK):** +```json +{ + "code": "success", + "data": { + "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV", + "name": "Updated Widget", + "description": "A useful widget", + "price": 39.99, + "category": "gadgets", + "sku": "WDG-001", + "isActive": true + } +} +``` + +### Delete Product (Soft Delete) + +```bash +curl -X DELETE http://localhost:3000/v1/products/01ARZ3NDEKTSV4RRFFQ69G5FAV +``` + +**Response (204 No Content)** + +## Architecture + +### ExpressJS Layered Architecture + +``` +Request → Router → Middleware → Controller → Service → Repository → PostgreSQL + ↓ (Validation) + Validation (Zod) +``` + +**Key Principles:** +- **Dependency Inversion**: Services depend on repository interfaces, not concrete implementations +- **Single Responsibility**: Each layer has a clear purpose +- **DRY**: Validation logic centralized in middleware + +**File Structure:** +``` +src/problem5/ +├── index.ts # Barrel export & server startup +├── config.ts # Configuration management +├── logger.ts # Pino logger instance +├── container.ts # DI container & lifecycle management +├── router.ts # Route definitions with validation middleware +├── controller/ +│ └── product-controller.ts # HTTP layer (orchestration only) +├── service/ +│ ├── interfaces.ts # IProductService interface +│ └── product-service.ts # Business logic, domain rules +├── repository/ +│ ├── interfaces.ts # IProductRepository interface +│ ├── database.ts # PostgreSQL pool & migration +│ ├── db-errors.ts # Database error type guards +│ └── product-repository.ts # Product data access layer +├── model/ +│ ├── product.ts # Domain types and interfaces +│ └── errors.ts # Custom error classes +├── schema/ +│ └── product.ts # Zod validation schemas +├── dto/ +│ ├── product-dto.ts # DTO interfaces +│ └── mapper.ts # Domain-to-DTO mapping +├── types/ +│ ├── branded.ts # ULID generation & validation +│ ├── index.ts # Type barrel export +│ └── express.d.ts # Express Request augmentation +├── middleware/ +│ ├── async-handler.ts # Async error wrapper +│ ├── error-handler.ts # Global error handler +│ ├── request-context.ts # Request ID & child logger +│ └── validate.ts # Zod validation middleware (assigns parsed data) +└── __tests__/ + ├── service/ + │ └── product-service.test.ts + └── controller/ + └── product-controller.test.ts +``` + +## Design Decisions + +For detailed design decisions and rationale, see **[DECISIONS.md](./DECISIONS.md)**. + +**Key decisions summary:** + +1. **Domain:** Product resource — universally understood, naturally supports filters +2. **Database:** PostgreSQL — production-grade with JSONB, full-text search, and scalability +3. **Schema:** Includes `metadata` JSONB column for extensibility, `deleted_at` for soft delete +4. **Filters:** 4 basic filters (category, minPrice, maxPrice, search) + pagination, extensible design +5. **Versioning:** URL path versioning (`/v1/products`) — industry standard, easy to test +6. **ID Type:** String in model/API, convert at repository boundary — future-proof for UUID migration +7. **Error Handling:** HTTP status codes with snake_case `code` field for machine-readable errors +8. **Validation:** Zod schemas in middleware at router level — parsed data assigned back to request +9. **Testing:** Unit tests for service (mock repo interface), integration tests for controller (mocked DB) +10. **Logging:** pino with request ID correlation +11. **Configuration:** Environment variables with sensible defaults +12. **Soft Delete:** `deleted_at` timestamp instead of hard delete — data recovery, audit trail +13. **Repository:** Self-contained `ProductRepository` — no leaky abstractions, all SQL parameterized +14. **Container:** Proper DI container with graceful shutdown hooks +15. **Middleware:** Reusable middleware for validation, async handling, request IDs +16. **Interfaces:** `IProductRepository` and `IProductService` for dependency inversion and testability +17. **Performance:** Separate COUNT query for pagination — avoids expensive window functions on large datasets + +## Error Handling + +All errors follow a consistent format with a machine-readable `code` field: + +```json +{ + "error": { + "code": "invalid_input", + "message": "Invalid input", + "details": [ + { "path": ["price"], "message": "Number must be greater than 0" } + ] + } +} +``` + +**Error Codes:** +| Code | HTTP Status | Description | +|---|---|---| +| `invalid_input` | `400` | Validation error | +| `not_found` | `404` | Resource not found | +| `duplicate_sku` | `409` | Duplicate SKU | +| `conflict` | `409` | Generic conflict | +| `internal_server_error` | `500` | Unexpected error | + +**HTTP Status Codes:** +- `200 OK` - Successful GET/PATCH +- `201 Created` - Successful POST +- `204 No Content` - Successful DELETE +- `400 Bad Request` - Validation error +- `404 Not Found` - Resource not found +- `409 Conflict` - Duplicate SKU +- `500 Internal Server Error` - Unexpected error + +## Testing + +**Unit Tests (Service Layer):** +- Mock repository interface to isolate business logic +- Test conflict detection, not-found handling, transaction behavior + +**Integration Tests (Controller Layer):** +- Mock repository layer for fast, self-contained tests +- Test HTTP layer: request validation (via middleware), response format, status codes + +**Run tests:** +```bash +npm test +``` + +## Extensibility + +This API is designed for future enhancements: + +1. **Custom Attributes:** Use the `metadata` JSONB field for category-specific attributes (queryable with PostgreSQL JSONB operators) +2. **Advanced Filtering:** Add new filter fields to `ProductFilter` interface (3-line change) +3. **New Resources:** Create new repository classes implementing the same interface pattern +4. **Relationships:** Add foreign keys to link products to categories, variants, etc. +5. **Authentication:** Add middleware for API keys or JWT tokens +6. **Rate Limiting:** Add Express middleware for request throttling +7. **UUID Migration:** Change ID generation without model changes (string IDs) +8. **Full-Text Search:** PostgreSQL native `tsvector` with GIN index + +## Known Limitations & Production Considerations + +**Search Performance:** +- Uses `ILIKE '%search%'` with pg_trgm GIN index for efficient trigram matching +- Requires pg_trgm PostgreSQL extension (automatically enabled in migration) +- For advanced search needs (ranking, highlighting, facets), consider: + - PostgreSQL full-text search with `tsvector` + GIN index + - Elasticsearch for complex search with ranking, highlighting, facets + +**Database Connection:** +- Connection pool configured via `DB_MAX_CONNECTIONS` +- Monitor connection usage in production +- Consider connection pooling at infrastructure level (e.g., PgBouncer) + +**Metadata Size:** +- Limited to 20 keys via Zod validation +- For large metadata, consider separate table or external storage + +## Database Performance & Indexes + +### Index Strategy + +The database uses optimized indexes based on query patterns and data assumptions: + +**Data Assumptions:** +- Most products are active (deleted_at IS NULL) - typically 90%+ of rows +- Category has moderate cardinality (10-100 distinct values) +- Price distribution is relatively uniform +- Search terms are typically 3+ characters +- Results are sorted by created_at DESC (newest first) + +**Indexes:** + +| Index | Purpose | Query Pattern | +|-------|---------|---------------| +| `idx_products_sku` | SKU uniqueness | `findBySku`, `findBySkuForUpdate` | +| `idx_products_active_price` | Price range filters | `WHERE price >= $1 AND price <= $2` | +| `idx_products_active_created` | Sorting by date | `ORDER BY created_at DESC` | +| `idx_products_active_category_created` | Category + sort | `WHERE category = $1 ORDER BY created_at DESC` | +| `idx_products_name_trgm` | ILIKE search | `WHERE name ILIKE '%term%'` | + +**Partial Indexes:** +- All indexes (except SKU) use `WHERE deleted_at IS NULL` +- Reduces index size by ~90% (only indexes active products) +- Improves query performance for the common case + +**Performance Characteristics:** + +| Operation | Complexity | Notes | +|-----------|-----------|-------| +| Category filter + sort | O(log n) | Composite index scan | +| Price range filter | O(log n) | Index range scan | +| Search (ILIKE) | O(log n) | Trigram index scan | +| Pagination | O(1) | Efficient with indexes | + +### Monitoring Query Performance + +Use PostgreSQL's `EXPLAIN ANALYZE` to verify index usage: + +```sql +-- Check if indexes are being used +EXPLAIN ANALYZE +SELECT * FROM products +WHERE deleted_at IS NULL + AND category = 'electronics' + AND price BETWEEN 10 AND 100 +ORDER BY created_at DESC +LIMIT 20; +``` + +### Scaling Considerations + +**Current Limits:** +- Single PostgreSQL instance with connection pooling +- Suitable for ~100k-500k products with current indexes +- Search performance degrades with very large datasets (>1M rows) + +**Scaling Strategies:** +1. **Read Replicas:** Offload read queries to replicas +2. **Partitioning:** Partition by category or date range +3. **Full-Text Search:** Migrate to `tsvector` for better search ranking +4. **Elasticsearch:** For complex search with facets, highlighting +5. **Caching:** Redis for frequently accessed products + +## License + +MIT diff --git a/src/problem5/__tests__/controller/product-controller.test.ts b/src/problem5/__tests__/controller/product-controller.test.ts new file mode 100644 index 0000000000..320644f1d8 --- /dev/null +++ b/src/problem5/__tests__/controller/product-controller.test.ts @@ -0,0 +1,252 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import express from 'express'; +import { ProductController } from '../../controller/product-controller'; +import { ProductService } from '../../service/product-service'; +import { IProductRepository } from '../../repository/interfaces'; +import { errorHandler } from '../../middleware/error-handler'; +import { validate } from '../../middleware/validate'; +import { productIdParam, createProductSchema, updateProductSchema, listProductsSchema } from '../../schema/product'; +import { Product } from '../../model/product'; + +describe('ProductController Integration', () => { + let app: express.Express; + let mockRepository: IProductRepository; + let service: ProductService; + let controller: ProductController; + + const mockProduct: Product = { + id: '01ARZ3NDEKTSV4RRFFQ69G5FAV', + name: 'Widget', + description: 'A useful widget', + price: 29.99, + category: 'gadgets', + sku: 'WDG-001', + isActive: true, + metadata: {}, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }; + + beforeEach(() => { + mockRepository = { + create: vi.fn().mockResolvedValue(mockProduct), + findById: vi.fn().mockResolvedValue(mockProduct), + findBySku: vi.fn().mockResolvedValue(null), + findBySkuForUpdate: vi.fn().mockResolvedValue(null), + findAll: vi.fn().mockResolvedValue({ products: [mockProduct], total: 1 }), + updateProduct: vi.fn().mockResolvedValue({ ...mockProduct, name: 'Updated' }), + delete: vi.fn().mockResolvedValue(true), + transaction: vi.fn().mockImplementation(async (fn) => { + const mockClient = {}; + return fn(mockClient); + }), + }; + + service = new ProductService(mockRepository); + controller = new ProductController(service); + + app = express(); + app.use(express.json({ limit: '10kb' })); + app.post('/v1/products', validate({ body: createProductSchema }), controller.create); + app.get('/v1/products', validate({ query: listProductsSchema }), controller.list); + app.get('/v1/products/:id', validate({ params: productIdParam }), controller.getById); + app.patch('/v1/products/:id', validate({ params: productIdParam, body: updateProductSchema }), controller.update); + app.delete('/v1/products/:id', validate({ params: productIdParam }), controller.delete); + app.use(errorHandler); + }); + + describe('POST /v1/products', () => { + it('should create a product and return 201', async () => { + const response = await request(app) + .post('/v1/products') + .send({ + name: 'Widget', + price: 29.99, + category: 'gadgets', + sku: 'WDG-001', + }); + + expect(response.status).toBe(201); + expect(response.body.code).toBe('success'); + expect(response.body.data).toHaveProperty('id'); + expect(response.body.data.name).toBe('Widget'); + expect(response.body.data.price).toBe(29.99); + expect(response.body.data).not.toHaveProperty('createdAt'); + expect(response.body.data).not.toHaveProperty('updatedAt'); + expect(response.body.data).not.toHaveProperty('metadata'); + }); + + it('should return 400 for invalid input', async () => { + const response = await request(app) + .post('/v1/products') + .send({ + name: '', + price: -10, + category: '', + sku: 'invalid sku', + }); + + expect(response.status).toBe(400); + expect(response.body.error).toHaveProperty('code', 'invalid_input'); + expect(response.body.error).toHaveProperty('message'); + }); + + it('should return 409 for duplicate SKU', async () => { + const error = new Error('Unique constraint violation') as any; + error.code = '23505'; + mockRepository.create = vi.fn().mockRejectedValue(error); + + const response = await request(app) + .post('/v1/products') + .send({ name: 'Product 1', price: 10, category: 'test', sku: 'DUP-001' }); + + expect(response.status).toBe(409); + expect(response.body.error).toHaveProperty('code', 'duplicate_sku'); + }); + }); + + describe('GET /v1/products', () => { + it('should list products with pagination', async () => { + const response = await request(app) + .get('/v1/products') + .query({ page: 1, limit: 10 }); + + expect(response.status).toBe(200); + expect(response.body.code).toBe('success'); + expect(response.body.data).toHaveLength(1); + expect(response.body.meta.total).toBe(1); + expect(response.body.meta.page).toBe(1); + expect(response.body.meta.limit).toBe(10); + }); + + it('should filter by category', async () => { + const filteredProduct = { ...mockProduct, category: 'gadgets' }; + mockRepository.findAll = vi.fn().mockResolvedValue({ products: [filteredProduct], total: 1 }); + + const response = await request(app) + .get('/v1/products') + .query({ category: 'gadgets' }); + + expect(response.status).toBe(200); + expect(response.body.data).toHaveLength(1); + expect(response.body.data[0].category).toBe('gadgets'); + }); + + it('should filter by price range', async () => { + mockRepository.findAll = vi.fn().mockResolvedValue({ products: [], total: 0 }); + + const response = await request(app) + .get('/v1/products') + .query({ minPrice: 10, maxPrice: 50 }); + + expect(response.status).toBe(200); + expect(response.body.data).toHaveLength(0); + }); + + it('should search by name', async () => { + const searchedProduct = { ...mockProduct, name: 'Widget Pro' }; + mockRepository.findAll = vi.fn().mockResolvedValue({ products: [searchedProduct], total: 1 }); + + const response = await request(app) + .get('/v1/products') + .query({ search: 'widget' }); + + expect(response.status).toBe(200); + expect(response.body.data).toHaveLength(1); + expect(response.body.data[0].name).toBe('Widget Pro'); + }); + }); + + describe('GET /v1/products/:id', () => { + it('should return product by ID', async () => { + const response = await request(app).get('/v1/products/01ARZ3NDEKTSV4RRFFQ69G5FAV'); + + expect(response.status).toBe(200); + expect(response.body.code).toBe('success'); + expect(response.body.data.id).toBe('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + }); + + it('should return 404 for non-existent product', async () => { + mockRepository.findById = vi.fn().mockResolvedValue(null); + + const response = await request(app).get('/v1/products/01ARZ3NDEKTSV4RRFFQ69G5FZZ'); + + expect(response.status).toBe(404); + expect(response.body.error).toHaveProperty('code', 'not_found'); + }); + + it('should return 400 for invalid ID format', async () => { + const response = await request(app).get('/v1/products/abc'); + + expect(response.status).toBe(400); + expect(response.body.error).toHaveProperty('code', 'invalid_input'); + }); + }); + + describe('PATCH /v1/products/:id', () => { + it('should update product partially', async () => { + const updatedProduct = { ...mockProduct, name: 'Updated', price: 39.99 }; + mockRepository.updateProduct = vi.fn().mockResolvedValue(updatedProduct); + + const response = await request(app) + .patch('/v1/products/01ARZ3NDEKTSV4RRFFQ69G5FAV') + .send({ name: 'Updated', price: 39.99 }); + + expect(response.status).toBe(200); + expect(response.body.code).toBe('success'); + expect(response.body.data.name).toBe('Updated'); + expect(response.body.data.price).toBe(39.99); + }); + + it('should return 404 for non-existent product', async () => { + mockRepository.updateProduct = vi.fn().mockResolvedValue(null); + + const response = await request(app) + .patch('/v1/products/01ARZ3NDEKTSV4RRFFQ69G5FZZ') + .send({ name: 'Updated' }); + + expect(response.status).toBe(404); + expect(response.body.error).toHaveProperty('code', 'not_found'); + }); + + it('should return 409 when updating to existing SKU', async () => { + const existingProduct = { ...mockProduct, id: '01ARZ3NDEKTSV4RRFFQ69G5FBW', sku: 'EXIST-001' }; + mockRepository.findBySkuForUpdate = vi.fn().mockResolvedValue(existingProduct); + + const response = await request(app) + .patch('/v1/products/01ARZ3NDEKTSV4RRFFQ69G5FAV') + .send({ sku: 'EXIST-001' }); + + expect(response.status).toBe(409); + expect(response.body.error).toHaveProperty('code', 'duplicate_sku'); + }); + + it('should return 400 for empty update body', async () => { + const response = await request(app) + .patch('/v1/products/01ARZ3NDEKTSV4RRFFQ69G5FAV') + .send({}); + + expect(response.status).toBe(400); + expect(response.body.error).toHaveProperty('code', 'invalid_input'); + }); + }); + + describe('DELETE /v1/products/:id', () => { + it('should soft delete product and return 204', async () => { + const response = await request(app).delete('/v1/products/01ARZ3NDEKTSV4RRFFQ69G5FAV'); + + expect(response.status).toBe(204); + expect(mockRepository.delete).toHaveBeenCalledWith('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + }); + + it('should return 404 for non-existent product', async () => { + mockRepository.delete = vi.fn().mockResolvedValue(false); + + const response = await request(app).delete('/v1/products/01ARZ3NDEKTSV4RRFFQ69G5FZZ'); + + expect(response.status).toBe(404); + expect(response.body.error).toHaveProperty('code', 'not_found'); + }); + }); +}); diff --git a/src/problem5/__tests__/service/product-service.test.ts b/src/problem5/__tests__/service/product-service.test.ts new file mode 100644 index 0000000000..1534009826 --- /dev/null +++ b/src/problem5/__tests__/service/product-service.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ProductService } from '../../service/product-service'; +import { IProductRepository } from '../../repository/interfaces'; +import { NotFoundError, ConflictError } from '../../model/errors'; +import { Product } from '../../model/product'; + +describe('ProductService', () => { + let service: ProductService; + let mockRepository: IProductRepository; + + const mockProduct: Product = { + id: '01ARZ3NDEKTSV4RRFFQ69G5FAV', + name: 'Test Product', + description: 'Test description', + price: 29.99, + category: 'test', + sku: 'TEST-001', + isActive: true, + metadata: {}, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }; + + beforeEach(() => { + mockRepository = { + create: vi.fn().mockResolvedValue(mockProduct), + findById: vi.fn().mockResolvedValue(mockProduct), + findBySku: vi.fn().mockResolvedValue(null), + findBySkuForUpdate: vi.fn().mockResolvedValue(null), + findAll: vi.fn().mockResolvedValue({ products: [mockProduct], total: 1 }), + updateProduct: vi.fn().mockResolvedValue({ ...mockProduct, name: 'Updated' }), + delete: vi.fn().mockResolvedValue(true), + transaction: vi.fn().mockImplementation(async (fn) => { + const mockClient = {}; + return fn(mockClient); + }), + }; + service = new ProductService(mockRepository); + }); + + describe('create', () => { + it('should create a product successfully', async () => { + const input = { + name: 'Test Product', + price: 29.99, + category: 'test', + sku: 'TEST-001', + }; + + const result = await service.create(input); + + expect(result).toEqual(mockProduct); + expect(mockRepository.create).toHaveBeenCalledWith(input); + }); + + it('should throw ConflictError when SKU already exists', async () => { + const error = new Error('Unique constraint violation') as any; + error.code = '23505'; + mockRepository.create = vi.fn().mockRejectedValue(error); + + await expect(service.create({ name: 'Test', price: 10, category: 'test', sku: 'TEST-001' })) + .rejects.toThrow(ConflictError); + }); + }); + + describe('getById', () => { + it('should return product when it exists', async () => { + const result = await service.getById('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + expect(result).toEqual(mockProduct); + }); + + it('should throw NotFoundError when product does not exist', async () => { + mockRepository.findById = vi.fn().mockResolvedValue(null); + + await expect(service.getById('01ARZ3NDEKTSV4RRFFQ69G5FZZ')).rejects.toThrow(NotFoundError); + }); + }); + + describe('list', () => { + it('should return products with filters', async () => { + const filter = { + page: 1, + limit: 20, + }; + + const result = await service.list(filter); + + expect(result.products).toEqual([mockProduct]); + expect(result.total).toBe(1); + }); + }); + + describe('update', () => { + it('should update product successfully', async () => { + const input = { name: 'Updated' }; + const result = await service.update('01ARZ3NDEKTSV4RRFFQ69G5FAV', input); + + expect(result.name).toBe('Updated'); + }); + + it('should throw NotFoundError when product does not exist', async () => { + mockRepository.updateProduct = vi.fn().mockResolvedValue(null); + + await expect(service.update('01ARZ3NDEKTSV4RRFFQ69G5FZZ', { name: 'Updated' })).rejects.toThrow(NotFoundError); + }); + + it('should throw ConflictError when updating to existing SKU', async () => { + const existingProduct = { ...mockProduct, id: '01ARZ3NDEKTSV4RRFFQ69G5FBW', sku: 'EXISTING-001' }; + mockRepository.findBySkuForUpdate = vi.fn().mockResolvedValue(existingProduct); + + await expect(service.update('01ARZ3NDEKTSV4RRFFQ69G5FAV', { sku: 'EXISTING-001' })).rejects.toThrow(ConflictError); + }); + + it('should throw ConflictError on unique constraint violation during update', async () => { + const error = new Error('Unique constraint violation') as any; + error.code = '23505'; + mockRepository.transaction = vi.fn().mockRejectedValue(error); + + await expect(service.update('01ARZ3NDEKTSV4RRFFQ69G5FAV', { sku: 'NEW-001' })) + .rejects.toThrow(ConflictError); + }); + }); + + describe('delete', () => { + it('should delete product successfully', async () => { + await expect(service.delete('01ARZ3NDEKTSV4RRFFQ69G5FAV')).resolves.not.toThrow(); + expect(mockRepository.delete).toHaveBeenCalledWith('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + }); + + it('should throw NotFoundError when product does not exist', async () => { + mockRepository.delete = vi.fn().mockResolvedValue(false); + + await expect(service.delete('01ARZ3NDEKTSV4RRFFQ69G5FZZ')).rejects.toThrow(NotFoundError); + }); + }); +}); diff --git a/src/problem5/config.ts b/src/problem5/config.ts new file mode 100644 index 0000000000..77c5d98d9a --- /dev/null +++ b/src/problem5/config.ts @@ -0,0 +1,65 @@ +import { z } from 'zod'; + +const envSchema = z.object({ + PORT: z.coerce.number().int().positive().default(3000), + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'), + SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().positive().default(10000), + DB_HOST: z.string().min(1, 'DB_HOST environment variable is required'), + DB_PORT: z.coerce.number().int().positive().default(5432), + DB_USER: z.string().min(1, 'DB_USER environment variable is required'), + DB_PASSWORD: z.string().min(1, 'DB_PASSWORD environment variable is required'), + DB_NAME: z.string().min(1, 'DB_NAME environment variable is required'), + DB_MAX_CONNECTIONS: z.coerce.number().int().positive().default(10), + DB_CONNECTION_TIMEOUT_MS: z.coerce.number().int().positive().default(5000), + DB_QUERY_TIMEOUT_MS: z.coerce.number().int().positive().default(10000), +}); + +const configSchema = z.object({ + port: z.number(), + nodeEnv: z.enum(['development', 'production', 'test']), + logLevel: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']), + shutdownTimeoutMs: z.number(), + database: z.object({ + host: z.string(), + port: z.number(), + user: z.string(), + password: z.string(), + database: z.string(), + maxConnections: z.number(), + connectionTimeoutMs: z.number(), + queryTimeoutMs: z.number(), + }), +}); + +type Config = z.infer; + +const parseConfig = (): Config => { + try { + const env = envSchema.parse(process.env); + return { + port: env.PORT, + nodeEnv: env.NODE_ENV, + logLevel: env.LOG_LEVEL, + shutdownTimeoutMs: env.SHUTDOWN_TIMEOUT_MS, + database: { + host: env.DB_HOST, + port: env.DB_PORT, + user: env.DB_USER, + password: env.DB_PASSWORD, + database: env.DB_NAME, + maxConnections: env.DB_MAX_CONNECTIONS, + connectionTimeoutMs: env.DB_CONNECTION_TIMEOUT_MS, + queryTimeoutMs: env.DB_QUERY_TIMEOUT_MS, + }, + }; + } catch (error) { + if (error instanceof z.ZodError) { + const messages = error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '); + throw new Error(`Configuration validation failed: ${messages}`); + } + throw error; + } +}; + +export const config: Config = parseConfig(); diff --git a/src/problem5/container.ts b/src/problem5/container.ts new file mode 100644 index 0000000000..c1f2e73b09 --- /dev/null +++ b/src/problem5/container.ts @@ -0,0 +1,76 @@ +import { Pool } from 'pg'; +import { ProductRepository } from './repository/product-repository'; +import { ProductService } from './service/product-service'; +import { ProductController } from './controller/product-controller'; +import { IProductRepository } from './repository/interfaces'; +import { IProductService } from './service/interfaces'; +import { createDatabase, migrate, closeDatabase, checkDatabaseHealth } from './repository/database'; +import { logger } from './logger'; + +class Container { + private pool: Pool | null = null; + private repository: IProductRepository | null = null; + private service: IProductService | null = null; + private controller: ProductController | null = null; + private initialized = false; + private initPromise: Promise | null = null; + + async init(): Promise { + if (this.initialized) return; + if (this.initPromise) return this.initPromise; + + this.initPromise = this.doInit(); + await this.initPromise; + } + + private async doInit(): Promise { + try { + this.pool = await createDatabase(); + await migrate(this.pool); + this.repository = new ProductRepository(this.pool); + this.service = new ProductService(this.repository); + this.controller = new ProductController(this.service); + this.initialized = true; + + logger.info('Application container initialized'); + } catch (error) { + logger.error({ error }, 'Failed to initialize container'); + this.initPromise = null; + throw error; + } + } + + getController(): ProductController { + if (!this.controller) { + throw new Error('Container not initialized. Call init() first.'); + } + return this.controller; + } + + getPool(): Pool { + if (!this.pool) { + throw new Error('Container not initialized. Call init() first.'); + } + return this.pool; + } + + async healthCheck(): Promise { + if (!this.pool) return false; + return checkDatabaseHealth(this.pool); + } + + async shutdown(): Promise { + if (!this.initialized || !this.pool) return; + + logger.info('Shutting down application container...'); + await closeDatabase(this.pool); + this.pool = null; + this.repository = null; + this.service = null; + this.controller = null; + this.initialized = false; + this.initPromise = null; + } +} + +export const container = new Container(); diff --git a/src/problem5/controller/product-controller.ts b/src/problem5/controller/product-controller.ts new file mode 100644 index 0000000000..1f96679dba --- /dev/null +++ b/src/problem5/controller/product-controller.ts @@ -0,0 +1,44 @@ +import { Request, Response } from 'express'; +import { IProductService } from '../service/interfaces'; +import { asyncHandler } from '../middleware/async-handler'; +import { toProductDTO, toProductListDTO } from '../dto/mapper'; +import { ProductFilter } from '../model/product'; + +export class ProductController { + constructor(private readonly productService: IProductService) {} + + create = asyncHandler(async (req: Request, res: Response) => { + const product = await this.productService.create(req.body); + res.status(201).json({ code: 'success', data: toProductDTO(product) }); + }); + + list = asyncHandler(async (req: Request, res: Response) => { + const filter = req.query as unknown as ProductFilter; + const result = await this.productService.list(filter); + const dto = toProductListDTO(result.products, result.total); + res.json({ + code: 'success', + data: dto.products, + meta: { + total: dto.total, + page: filter.page, + limit: filter.limit, + }, + }); + }); + + getById = asyncHandler(async (req: Request, res: Response) => { + const product = await this.productService.getById(req.params.id); + res.json({ code: 'success', data: toProductDTO(product) }); + }); + + update = asyncHandler(async (req: Request, res: Response) => { + const product = await this.productService.update(req.params.id, req.body); + res.json({ code: 'success', data: toProductDTO(product) }); + }); + + delete = asyncHandler(async (req: Request, res: Response) => { + await this.productService.delete(req.params.id); + res.status(204).send(); + }); +} diff --git a/src/problem5/docker-compose.yml b/src/problem5/docker-compose.yml new file mode 100644 index 0000000000..c5998d0652 --- /dev/null +++ b/src/problem5/docker-compose.yml @@ -0,0 +1,45 @@ +services: + postgres: + image: postgres:16-alpine + container_name: problem5-postgres + environment: + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} + POSTGRES_DB: ${DB_NAME:-products_db} + ports: + - "${DB_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres}"] + interval: 10s + timeout: 5s + retries: 5 + + app: + build: + context: ../.. + dockerfile: src/problem5/Dockerfile + container_name: problem5-app + environment: + PORT: ${PORT:-3000} + DB_HOST: postgres + DB_PORT: 5432 + DB_USER: ${DB_USER:-postgres} + DB_PASSWORD: ${DB_PASSWORD:-postgres} + DB_NAME: ${DB_NAME:-products_db} + DB_MAX_CONNECTIONS: ${DB_MAX_CONNECTIONS:-10} + DB_CONNECTION_TIMEOUT_MS: ${DB_CONNECTION_TIMEOUT_MS:-5000} + DB_QUERY_TIMEOUT_MS: ${DB_QUERY_TIMEOUT_MS:-10000} + NODE_ENV: ${NODE_ENV:-development} + LOG_LEVEL: ${LOG_LEVEL:-info} + SHUTDOWN_TIMEOUT_MS: ${SHUTDOWN_TIMEOUT_MS:-10000} + ports: + - "${PORT:-3000}:3000" + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped + +volumes: + postgres_data: diff --git a/src/problem5/dto/mapper.ts b/src/problem5/dto/mapper.ts new file mode 100644 index 0000000000..b1fddd2568 --- /dev/null +++ b/src/problem5/dto/mapper.ts @@ -0,0 +1,17 @@ +import { Product } from '../model/product'; +import { ProductDTO, ProductListDTO } from './product-dto'; + +export const toProductDTO = (product: Product): ProductDTO => ({ + id: product.id, + name: product.name, + description: product.description, + price: product.price, + category: product.category, + sku: product.sku, + isActive: product.isActive, +}); + +export const toProductListDTO = (products: readonly Product[], total: number): ProductListDTO => ({ + products: products.map(toProductDTO), + total, +}); diff --git a/src/problem5/dto/product-dto.ts b/src/problem5/dto/product-dto.ts new file mode 100644 index 0000000000..fadd71f755 --- /dev/null +++ b/src/problem5/dto/product-dto.ts @@ -0,0 +1,14 @@ +export interface ProductDTO { + readonly id: string; + readonly name: string; + readonly description: string; + readonly price: number; + readonly category: string; + readonly sku: string; + readonly isActive: boolean; +} + +export interface ProductListDTO { + readonly products: readonly ProductDTO[]; + readonly total: number; +} diff --git a/src/problem5/index.ts b/src/problem5/index.ts new file mode 100644 index 0000000000..8f2bbc9fea --- /dev/null +++ b/src/problem5/index.ts @@ -0,0 +1,82 @@ +import express from 'express'; +import { problem5Router } from './router'; +import { container } from './container'; +import { logger } from './logger'; +import { config } from './config'; + +const createApp = (): express.Express => { + const app = express(); + + app.get('/health/liveness', (_req, res) => { + res.json({ status: 'ok' }); + }); + + app.get('/health/readiness', async (_req, res) => { + const isHealthy = await container.healthCheck(); + if (isHealthy) { + res.json({ status: 'ok' }); + } else { + res.status(503).json({ status: 'error', message: 'Database unavailable' }); + } + }); + + app.use('/v1/products', problem5Router); + + return app; +}; + +const startServer = async (): Promise => { + try { + await container.init(); + + const app = createApp(); + const server = app.listen(config.port, () => { + logger.info({ port: config.port }, 'Server started'); + }); + + const gracefulShutdown = async (signal: string): Promise => { + logger.info({ signal }, 'Received shutdown signal'); + + server.close(async () => { + logger.info('HTTP server closed'); + try { + await container.shutdown(); + logger.info('Graceful shutdown completed'); + process.exit(0); + } catch (error) { + logger.error({ error }, 'Error during shutdown'); + process.exit(1); + } + }); + + setTimeout(() => { + logger.error('Forced shutdown after timeout'); + process.exit(1); + }, config.shutdownTimeoutMs); + }; + + process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); + process.on('SIGINT', () => gracefulShutdown('SIGINT')); + + process.on('unhandledRejection', (reason) => { + logger.error({ reason }, 'Unhandled Rejection'); + }); + + process.on('uncaughtException', (error) => { + logger.error({ error }, 'Uncaught Exception'); + process.exit(1); + }); + } catch (error) { + logger.error({ error }, 'Failed to start server'); + process.exit(1); + } +}; + +export { createApp, startServer, container, problem5Router, config, logger }; +export type { Product, CreateProductInput, UpdateProductInput, ProductFilter } from './model/product'; +export type { ProductDTO, ProductListDTO } from './dto/product-dto'; +export type { IProductRepository } from './repository/interfaces'; +export type { IProductService } from './service/interfaces'; +export { AppError, ValidationError, NotFoundError, ConflictError } from './model/errors'; + +startServer(); diff --git a/src/problem5/logger.ts b/src/problem5/logger.ts new file mode 100644 index 0000000000..b7d468d05d --- /dev/null +++ b/src/problem5/logger.ts @@ -0,0 +1,6 @@ +import pino from 'pino'; +import { config } from './config'; + +export const logger = pino({ + level: config.logLevel, +}); diff --git a/src/problem5/middleware/async-handler.ts b/src/problem5/middleware/async-handler.ts new file mode 100644 index 0000000000..a21b19e33f --- /dev/null +++ b/src/problem5/middleware/async-handler.ts @@ -0,0 +1,9 @@ +import { Request, Response, NextFunction, RequestHandler } from 'express'; + +type AsyncRequestHandler = (req: Request, res: Response, next: NextFunction) => Promise; + +export const asyncHandler = (fn: AsyncRequestHandler): RequestHandler => { + return (req: Request, res: Response, next: NextFunction): void => { + Promise.resolve(fn(req, res, next)).catch(next); + }; +}; diff --git a/src/problem5/middleware/error-handler.ts b/src/problem5/middleware/error-handler.ts new file mode 100644 index 0000000000..f04baf68d5 --- /dev/null +++ b/src/problem5/middleware/error-handler.ts @@ -0,0 +1,37 @@ +import { Request, Response, NextFunction, ErrorRequestHandler } from 'express'; +import { AppError } from '../model/errors'; + +export const errorHandler: ErrorRequestHandler = ( + error: Error, + req: Request, + res: Response, + _next: NextFunction +): void => { + const log = req.log; + const requestId = req.headers['x-request-id'] as string; + + if (error instanceof AppError) { + if (error.statusCode >= 500) { + log?.error({ err: error, url: req.url, requestId }, 'Server error'); + } else { + log?.warn({ err: error, url: req.url, requestId }, 'Client error'); + } + + res.status(error.statusCode).json({ + error: { + code: error.code, + message: error.message, + ...(error.details !== undefined && { details: error.details }), + }, + }); + return; + } + + log?.error({ err: error, url: req.url, requestId }, 'Unexpected error'); + res.status(500).json({ + error: { + code: 'internal_server_error', + message: 'Internal server error', + }, + }); +}; diff --git a/src/problem5/middleware/request-context.ts b/src/problem5/middleware/request-context.ts new file mode 100644 index 0000000000..8d1f78e243 --- /dev/null +++ b/src/problem5/middleware/request-context.ts @@ -0,0 +1,14 @@ +import { Request, Response, NextFunction, RequestHandler } from 'express'; +import { randomUUID } from 'crypto'; +import { logger } from '../logger'; + +export const requestContextMiddleware: RequestHandler = (req: Request, res: Response, next: NextFunction): void => { + const requestId = (req.headers['x-request-id'] as string) || randomUUID(); + + req.headers['x-request-id'] = requestId; + res.setHeader('X-Request-ID', requestId); + + req.log = logger.child({ requestId }); + + next(); +}; diff --git a/src/problem5/middleware/validate.ts b/src/problem5/middleware/validate.ts new file mode 100644 index 0000000000..eb16b1d889 --- /dev/null +++ b/src/problem5/middleware/validate.ts @@ -0,0 +1,49 @@ +import { Request, Response, NextFunction, RequestHandler } from 'express'; +import { ZodSchema } from 'zod'; +import { ValidationError } from '../model/errors'; + +interface ValidationSchema { + params?: ZodSchema; + query?: ZodSchema; + body?: ZodSchema; +} + +export const validate = (schema: ValidationSchema): RequestHandler => { + return (req: Request, _res: Response, next: NextFunction): void => { + const errors: { path: (string | number)[]; message: string }[] = []; + + if (schema.params) { + const result = schema.params.safeParse(req.params); + if (!result.success) { + result.error.errors.forEach((e) => errors.push({ path: e.path, message: e.message })); + } else { + req.params = result.data; + } + } + + if (schema.query) { + const result = schema.query.safeParse(req.query); + if (!result.success) { + result.error.errors.forEach((e) => errors.push({ path: e.path, message: e.message })); + } else { + req.query = result.data; + } + } + + if (schema.body) { + const result = schema.body.safeParse(req.body); + if (!result.success) { + result.error.errors.forEach((e) => errors.push({ path: e.path, message: e.message })); + } else { + req.body = result.data; + } + } + + if (errors.length > 0) { + next(new ValidationError(errors)); + return; + } + + next(); + }; +}; diff --git a/src/problem5/model/errors.ts b/src/problem5/model/errors.ts new file mode 100644 index 0000000000..7e79de61f7 --- /dev/null +++ b/src/problem5/model/errors.ts @@ -0,0 +1,31 @@ +export class AppError extends Error { + readonly statusCode: number; + readonly code: string; + readonly details?: unknown; + + constructor(statusCode: number, code: string, message: string, details?: unknown) { + super(message); + this.statusCode = statusCode; + this.code = code; + this.details = details; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class ValidationError extends AppError { + constructor(details: unknown) { + super(400, 'invalid_input', 'Invalid input', details); + } +} + +export class NotFoundError extends AppError { + constructor(resource: string, id: string) { + super(404, 'not_found', `${resource} with id ${id} not found`); + } +} + +export class ConflictError extends AppError { + constructor(message: string, code = 'conflict') { + super(409, code, message); + } +} diff --git a/src/problem5/model/product.ts b/src/problem5/model/product.ts new file mode 100644 index 0000000000..f9f865c8c4 --- /dev/null +++ b/src/problem5/model/product.ts @@ -0,0 +1,47 @@ +export interface Product { + readonly id: string; + readonly name: string; + readonly description: string; + readonly price: number; + readonly category: string; + readonly sku: string; + readonly isActive: boolean; + readonly metadata: Record; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface CreateProductInput { + name: string; + description?: string; + price: number; + category: string; + sku: string; + isActive?: boolean; + metadata?: Record; +} + +export type UpdateProductInput = Partial; + +export interface ProductFilter { + readonly category?: string; + readonly minPrice?: number; + readonly maxPrice?: number; + readonly search?: string; + readonly page: number; + readonly limit: number; +} + +export interface ProductRow { + readonly id: string; + readonly name: string; + readonly description: string; + readonly price: number; + readonly category: string; + readonly sku: string; + readonly is_active: boolean; + readonly metadata: Record; + readonly deleted_at: Date | null; + readonly created_at: Date; + readonly updated_at: Date; +} diff --git a/src/problem5/package.json b/src/problem5/package.json new file mode 100644 index 0000000000..0c1aad56f5 --- /dev/null +++ b/src/problem5/package.json @@ -0,0 +1,28 @@ +{ + "name": "code-challenge", + "version": "1.0.0", + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsx watch index.ts" + }, + "dependencies": { + "express": "^4.19.2", + "pg": "^8.11.5", + "pino": "^9.1.0", + "ulid": "^2.3.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.12.12", + "@types/pg": "^8.11.6", + "@types/supertest": "^6.0.2", + "supertest": "^7.0.0", + "tsx": "^4.11.0", + "typescript": "^5.4.5", + "vitest": "^1.6.0" + } +} diff --git a/src/problem5/repository/database.ts b/src/problem5/repository/database.ts new file mode 100644 index 0000000000..40acde1afa --- /dev/null +++ b/src/problem5/repository/database.ts @@ -0,0 +1,165 @@ +import { Pool } from 'pg'; +import { logger } from '../logger'; +import { config } from '../config'; + +export type Database = Pool; + +export const createDatabase = async (): Promise => { + const pool = new Pool({ + host: config.database.host, + port: config.database.port, + user: config.database.user, + password: config.database.password, + database: config.database.database, + max: config.database.maxConnections, + connectionTimeoutMillis: config.database.connectionTimeoutMs, + idleTimeoutMillis: 30000, + maxUses: 7500, + }); + + pool.on('error', (err) => { + logger.error({ err }, 'Unexpected PostgreSQL pool error'); + }); + + pool.on('connect', () => { + logger.debug('New client connected to database'); + }); + + pool.on('remove', () => { + logger.debug('Client removed from pool'); + }); + + return pool; +}; + +export const migrate = async (pool: Database): Promise => { + const client = await pool.connect(); + try { + await client.query(`SET statement_timeout = ${config.database.queryTimeoutMs}`); + + await client.query(` + CREATE TABLE IF NOT EXISTS products ( + id VARCHAR(26) PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + price NUMERIC(10, 2) NOT NULL CHECK(price >= 0), + category TEXT NOT NULL, + sku TEXT NOT NULL UNIQUE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + metadata JSONB NOT NULL DEFAULT '{}', + deleted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + + // ============================================================================ + // INDEXES - Performance Optimization + // ============================================================================ + // + // DATA ASSUMPTIONS: + // 1. Most queries filter by deleted_at IS NULL (soft delete pattern) + // 2. Price range queries (minPrice, maxPrice) are common filters + // 3. Results are typically sorted by created_at DESC (newest first) + // 4. Category filtering is frequently combined with sorting + // 5. Search uses ILIKE with leading wildcards (%term%) + // 6. Most products are active (deleted_at IS NULL) - typically 90%+ of rows + // + // STRATEGY: + // - Use partial indexes (WHERE deleted_at IS NULL) to reduce index size + // and improve performance for the common case (active products only) + // - Composite indexes for common filter+sort combinations + // - pg_trgm for ILIKE search (leading wildcard can't use B-tree) + // ============================================================================ + + // SKU lookup - unique constraint already creates an index + // Used by: findBySku, findBySkuForUpdate, create (uniqueness check) + await client.query(`CREATE INDEX IF NOT EXISTS idx_products_sku ON products(sku)`); + + // Price range queries - used when filtering by minPrice/maxPrice + // Partial index reduces size since we only query active products + await client.query(` + CREATE INDEX IF NOT EXISTS idx_products_active_price + ON products(price) + WHERE deleted_at IS NULL + `); + + // Created_at sorting - used for ORDER BY created_at DESC in list queries + // DESC index matches the query pattern (newest first) + await client.query(` + CREATE INDEX IF NOT EXISTS idx_products_active_created + ON products(created_at DESC) + WHERE deleted_at IS NULL + `); + + // Composite index: category + created_at + // Optimizes: WHERE category = $1 ORDER BY created_at DESC + // Common pattern: list products in a category, sorted by newest + await client.query(` + CREATE INDEX IF NOT EXISTS idx_products_active_category_created + ON products(category, created_at DESC) + WHERE deleted_at IS NULL + `); + + // ============================================================================ + // FULL-TEXT SEARCH - pg_trgm for ILIKE queries + // ============================================================================ + // + // PROBLEM: ILIKE '%term%' with leading wildcard causes full table scan + // SOLUTION: Use pg_trgm extension with GIN index for trigram matching + // + // ASSUMPTIONS: + // - Search is case-insensitive (ILIKE) + // - Partial matches are needed (not just prefix matching) + // - Dataset is large enough that full scans are unacceptable (>10k rows) + // - pg_trgm extension is available (standard in PostgreSQL 9.x+) + // + // TRADE-OFFS: + // - GIN index is larger than B-tree (~2-3x table size) + // - Index updates are slower on INSERT/UPDATE + // - Trigram matching works best for strings > 3 characters + // ============================================================================ + + try { + await client.query(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); + + // GIN trigram index for ILIKE search on product name + // Enables index scan instead of full table scan for: + // WHERE name ILIKE '%search_term%' + await client.query(` + CREATE INDEX IF NOT EXISTS idx_products_name_trgm + ON products USING gin (name gin_trgm_ops) + `); + + logger.info('pg_trgm extension enabled for search optimization'); + } catch (error) { + // pg_trgm might not be available in all PostgreSQL installations + // Log warning but don't fail migration - search will still work (just slower) + logger.warn({ error }, 'pg_trgm extension not available, search will use full table scan'); + } + + logger.info('Database migration completed'); + } finally { + client.release(); + } +}; + +export const closeDatabase = async (pool: Database): Promise => { + await pool.end(); + logger.info('Database connection closed'); +}; + +export const checkDatabaseHealth = async (pool: Database): Promise => { + try { + const client = await pool.connect(); + try { + await client.query('SELECT 1'); + return true; + } finally { + client.release(); + } + } catch (error) { + logger.error({ error }, 'Database health check failed'); + return false; + } +}; diff --git a/src/problem5/repository/db-errors.ts b/src/problem5/repository/db-errors.ts new file mode 100644 index 0000000000..cc3a9002e7 --- /dev/null +++ b/src/problem5/repository/db-errors.ts @@ -0,0 +1,11 @@ +interface DatabaseError { + code: string; +} + +export const isUniqueConstraintError = (error: unknown): error is DatabaseError => { + if (error !== null && typeof error === 'object' && 'code' in error) { + const code = (error as DatabaseError).code; + return typeof code === 'string' && code === '23505'; + } + return false; +}; \ No newline at end of file diff --git a/src/problem5/repository/interfaces.ts b/src/problem5/repository/interfaces.ts new file mode 100644 index 0000000000..d566d14023 --- /dev/null +++ b/src/problem5/repository/interfaces.ts @@ -0,0 +1,13 @@ +import { PoolClient } from 'pg'; +import { Product, CreateProductInput, UpdateProductInput, ProductFilter } from '../model/product'; + +export interface IProductRepository { + create(input: CreateProductInput): Promise; + findById(id: string): Promise; + findBySku(sku: string): Promise; + findBySkuForUpdate(sku: string, client: PoolClient): Promise; + findAll(filter: ProductFilter): Promise<{ products: Product[]; total: number }>; + updateProduct(id: string, input: UpdateProductInput, client?: PoolClient): Promise; + delete(id: string): Promise; + transaction(fn: (client: PoolClient) => Promise): Promise; +} diff --git a/src/problem5/repository/product-repository.ts b/src/problem5/repository/product-repository.ts new file mode 100644 index 0000000000..5d389af14a --- /dev/null +++ b/src/problem5/repository/product-repository.ts @@ -0,0 +1,228 @@ +import { PoolClient, QueryResultRow } from 'pg'; +import { Database } from './database'; +import { IProductRepository } from './interfaces'; +import { Product, ProductRow, ProductFilter, CreateProductInput, UpdateProductInput } from '../model/product'; +import { logger } from '../logger'; +import { generateProductId } from '../types/branded'; + +export class ProductRepository implements IProductRepository { + constructor(private readonly pool: Database) {} + + private mapRowToDomain(row: QueryResultRow): Product { + const productRow = row as ProductRow; + return { + id: productRow.id, + name: productRow.name, + description: productRow.description, + price: Number(productRow.price), + category: productRow.category, + sku: productRow.sku, + isActive: productRow.is_active, + metadata: productRow.metadata || {}, + createdAt: productRow.created_at.toISOString(), + updatedAt: productRow.updated_at.toISOString(), + }; + } + + async transaction(fn: (client: PoolClient) => Promise): Promise { + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + const result = await fn(client); + await client.query('COMMIT'); + return result; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + async create(input: CreateProductInput): Promise { + const id = generateProductId(); + const columns = ['id', 'name', 'description', 'price', 'category', 'sku', 'is_active', 'metadata']; + const values = [ + id, + input.name, + input.description || '', + input.price, + input.category, + input.sku, + input.isActive ?? true, + JSON.stringify(input.metadata || {}), + ]; + + const placeholders = columns.map((_, i) => `$${i + 1}`).join(', '); + const sql = ` + INSERT INTO products (${columns.join(', ')}) + VALUES (${placeholders}) + RETURNING * + `; + + const result = await this.pool.query(sql, values); + const product = this.mapRowToDomain(result.rows[0]); + logger.debug({ productId: product.id }, 'Product created in database'); + return product; + } + + async findById(id: string): Promise { + const result = await this.pool.query( + `SELECT * FROM products WHERE id = $1 AND deleted_at IS NULL`, + [id] + ); + return result.rows[0] ? this.mapRowToDomain(result.rows[0]) : null; + } + + async findBySku(sku: string): Promise { + const result = await this.pool.query( + `SELECT * FROM products WHERE sku = $1 AND deleted_at IS NULL`, + [sku] + ); + return result.rows[0] ? this.mapRowToDomain(result.rows[0]) : null; + } + + async findBySkuForUpdate(sku: string, client: PoolClient): Promise { + const result = await client.query( + `SELECT * FROM products WHERE sku = $1 AND deleted_at IS NULL FOR UPDATE`, + [sku] + ); + return result.rows[0] ? this.mapRowToDomain(result.rows[0]) : null; + } + + async findAll(filter: ProductFilter): Promise<{ products: Product[]; total: number }> { + // ============================================================================ + // QUERY OPTIMIZATION NOTES + // ============================================================================ + // + // INDEX STRATEGY: + // - deleted_at IS NULL: Partial indexes automatically filter soft-deleted rows + // - category: Uses idx_products_active_category_created (composite index) + // - price range: Uses idx_products_active_price + // - ORDER BY created_at DESC: Uses idx_products_active_created or composite index + // - ILIKE search: Uses idx_products_name_trgm (pg_trgm GIN index) + // + // PERFORMANCE CHARACTERISTICS: + // - Category filter + sort: O(log n) index scan + // - Price range filter: O(log n) index range scan + // - Search (ILIKE): O(log n) trigram index scan (vs O(n) full table scan) + // - Pagination (LIMIT/OFFSET): Efficient with proper indexes + // + // ASSUMPTIONS: + // - Most products are active (deleted_at IS NULL) - typically 90%+ + // - Category has moderate cardinality (10-100 distinct values) + // - Price distribution is relatively uniform + // - Search terms are typically 3+ characters (trigram effectiveness) + // ============================================================================ + + const conditions: string[] = ['deleted_at IS NULL']; + const params: unknown[] = []; + + if (filter.category) { + params.push(filter.category); + conditions.push(`category = $${params.length}`); + } + + if (filter.minPrice !== undefined) { + params.push(filter.minPrice); + conditions.push(`price >= $${params.length}`); + } + + if (filter.maxPrice !== undefined) { + params.push(filter.maxPrice); + conditions.push(`price <= $${params.length}`); + } + + if (filter.search) { + const escapedSearch = filter.search + .replace(/%/g, '\\%') + .replace(/_/g, '\\_'); + params.push(`%${escapedSearch}%`); + // ILIKE with leading wildcard - uses pg_trgm GIN index + // Without pg_trgm, this would cause a full table scan + conditions.push(`name ILIKE $${params.length} ESCAPE '\\'`); + } + + const whereClause = conditions.join(' AND '); + const offset = (filter.page - 1) * filter.limit; + + const dataParams = [...params, filter.limit, offset]; + const dataSql = ` + SELECT * FROM products + WHERE ${whereClause} + ORDER BY created_at DESC + LIMIT $${dataParams.length - 1} OFFSET $${dataParams.length} + `; + + const countSql = `SELECT COUNT(*) as total FROM products WHERE ${whereClause}`; + + const [dataResult, countResult] = await Promise.all([ + this.pool.query(dataSql, dataParams), + this.pool.query(countSql, params), + ]); + + const products = dataResult.rows.map((row) => this.mapRowToDomain(row)); + const total = parseInt(countResult.rows[0].total, 10); + + return { products, total }; + } + + async updateProduct(id: string, input: UpdateProductInput, client?: PoolClient): Promise { + const executor = client ?? this.pool; + + const fieldMap: Record = { + name: input.name, + description: input.description, + price: input.price, + category: input.category, + sku: input.sku, + is_active: input.isActive, + metadata: input.metadata !== undefined ? JSON.stringify(input.metadata) : undefined, + }; + + const updates: string[] = []; + const values: unknown[] = []; + + Object.entries(fieldMap).forEach(([column, value]) => { + if (value !== undefined) { + values.push(value); + updates.push(`${column} = $${values.length}`); + } + }); + + if (updates.length === 0) { + return this.findById(id); + } + + updates.push('updated_at = NOW()'); + + const setClause = updates.join(', '); + const sql = ` + UPDATE products + SET ${setClause} + WHERE id = $${values.length + 1} AND deleted_at IS NULL + RETURNING * + `; + const result = await executor.query(sql, [...values, id]); + const product = result.rows[0] ? this.mapRowToDomain(result.rows[0]) : null; + + if (product) { + logger.debug({ productId: id }, 'Product updated in database'); + } + return product; + } + + async delete(id: string): Promise { + const result = await this.pool.query( + `UPDATE products + SET deleted_at = NOW(), updated_at = NOW() + WHERE id = $1 AND deleted_at IS NULL`, + [id] + ); + const deleted = (result.rowCount ?? 0) > 0; + if (deleted) { + logger.debug({ productId: id }, 'Product soft deleted in database'); + } + return deleted; + } +} diff --git a/src/problem5/router.ts b/src/problem5/router.ts new file mode 100644 index 0000000000..be5d8f46ca --- /dev/null +++ b/src/problem5/router.ts @@ -0,0 +1,36 @@ +import { Router } from 'express'; +import { container } from './container'; +import { validate } from './middleware/validate'; +import { requestContextMiddleware } from './middleware/request-context'; +import { errorHandler } from './middleware/error-handler'; +import { productIdParam, createProductSchema, updateProductSchema, listProductsSchema } from './schema/product'; +import express from 'express'; + +const router = Router(); + +router.use(express.json({ limit: '10kb' })); +router.use(requestContextMiddleware); + +router.post('/', validate({ body: createProductSchema }), (req, res, next) => { + container.getController().create(req, res, next); +}); + +router.get('/', validate({ query: listProductsSchema }), (req, res, next) => { + container.getController().list(req, res, next); +}); + +router.get('/:id', validate({ params: productIdParam }), (req, res, next) => { + container.getController().getById(req, res, next); +}); + +router.patch('/:id', validate({ params: productIdParam, body: updateProductSchema }), (req, res, next) => { + container.getController().update(req, res, next); +}); + +router.delete('/:id', validate({ params: productIdParam }), (req, res, next) => { + container.getController().delete(req, res, next); +}); + +router.use(errorHandler); + +export { router as problem5Router }; diff --git a/src/problem5/schema/product.ts b/src/problem5/schema/product.ts new file mode 100644 index 0000000000..4d92bb0baf --- /dev/null +++ b/src/problem5/schema/product.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; +import { isValidULID } from '../types/branded'; + +export const productIdParam = z.object({ + id: z.string().refine(isValidULID, 'ID must be a valid ULID'), +}); + +const metadataSchema = z + .record(z.unknown()) + .refine( + (val) => Object.keys(val).length <= 20, + { message: 'Metadata must have at most 20 keys' } + ) + .optional() + .default({}); + +export const createProductSchema = z.object({ + name: z.string().min(1, 'Name is required').max(255), + description: z.string().max(5000).optional().default(''), + price: z.number().positive('Price must be positive'), + category: z.string().min(1, 'Category is required').max(100), + sku: z.string().min(1, 'SKU is required').max(50).regex(/^[A-Z0-9-]+$/, 'SKU must be uppercase alphanumeric with hyphens'), + isActive: z.boolean().optional().default(true), + metadata: metadataSchema, +}); + +export const updateProductSchema = createProductSchema.partial().refine( + (val) => Object.keys(val).length > 0, + { message: 'At least one field must be provided for update' } +); + +export const listProductsSchema = z.object({ + category: z.string().optional(), + minPrice: z.coerce.number().positive().optional(), + maxPrice: z.coerce.number().positive().optional(), + search: z.string().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), +}); diff --git a/src/problem5/service/interfaces.ts b/src/problem5/service/interfaces.ts new file mode 100644 index 0000000000..df655284f0 --- /dev/null +++ b/src/problem5/service/interfaces.ts @@ -0,0 +1,9 @@ +import { Product, CreateProductInput, UpdateProductInput, ProductFilter } from '../model/product'; + +export interface IProductService { + create(input: CreateProductInput): Promise; + getById(id: string): Promise; + list(filter: ProductFilter): Promise<{ products: Product[]; total: number }>; + update(id: string, input: UpdateProductInput): Promise; + delete(id: string): Promise; +} diff --git a/src/problem5/service/product-service.ts b/src/problem5/service/product-service.ts new file mode 100644 index 0000000000..44ae643e9e --- /dev/null +++ b/src/problem5/service/product-service.ts @@ -0,0 +1,63 @@ +import { IProductRepository } from '../repository/interfaces'; +import { IProductService } from './interfaces'; +import { Product, CreateProductInput, UpdateProductInput, ProductFilter } from '../model/product'; +import { NotFoundError, ConflictError } from '../model/errors'; +import { isUniqueConstraintError } from '../repository/db-errors'; + +export class ProductService implements IProductService { + constructor(private readonly repository: IProductRepository) {} + + async create(input: CreateProductInput): Promise { + try { + return await this.repository.create(input); + } catch (error) { + if (isUniqueConstraintError(error)) { + throw new ConflictError('Product with this SKU already exists', 'duplicate_sku'); + } + throw error; + } + } + + async getById(id: string): Promise { + const product = await this.repository.findById(id); + if (!product) { + throw new NotFoundError('Product', id); + } + return product; + } + + async list(filter: ProductFilter): Promise<{ products: Product[]; total: number }> { + return this.repository.findAll(filter); + } + + async update(id: string, input: UpdateProductInput): Promise { + try { + return await this.repository.transaction(async (client) => { + if (input.sku) { + const existingWithSku = await this.repository.findBySkuForUpdate(input.sku, client); + if (existingWithSku && existingWithSku.id !== id) { + throw new ConflictError('Product with this SKU already exists', 'duplicate_sku'); + } + } + + const updated = await this.repository.updateProduct(id, input, client); + if (!updated) { + throw new NotFoundError('Product', id); + } + return updated; + }); + } catch (error) { + if (isUniqueConstraintError(error)) { + throw new ConflictError('Product with this SKU already exists', 'duplicate_sku'); + } + throw error; + } + } + + async delete(id: string): Promise { + const deleted = await this.repository.delete(id); + if (!deleted) { + throw new NotFoundError('Product', id); + } + } +} diff --git a/src/problem5/tsconfig.json b/src/problem5/tsconfig.json new file mode 100644 index 0000000000..f8fba89e5d --- /dev/null +++ b/src/problem5/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": [ + "**/*.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/src/problem5/types/branded.ts b/src/problem5/types/branded.ts new file mode 100644 index 0000000000..a00a73a91b --- /dev/null +++ b/src/problem5/types/branded.ts @@ -0,0 +1,10 @@ +import { ulid } from 'ulid'; + +export const generateProductId = (): string => { + return ulid(); +}; + +export const isValidULID = (id: string): boolean => { + const ulidRegex = /^[0-9A-HJKMNP-TV-Z]{26}$/i; + return ulidRegex.test(id); +}; diff --git a/src/problem5/types/express.d.ts b/src/problem5/types/express.d.ts new file mode 100644 index 0000000000..dc3f98d77c --- /dev/null +++ b/src/problem5/types/express.d.ts @@ -0,0 +1,7 @@ +import { Logger } from 'pino'; + +declare module 'express-serve-static-core' { + interface Request { + log?: Logger; + } +} diff --git a/src/problem5/types/index.ts b/src/problem5/types/index.ts new file mode 100644 index 0000000000..a96c2bbdb7 --- /dev/null +++ b/src/problem5/types/index.ts @@ -0,0 +1 @@ +export * from './branded'; diff --git a/src/problem5/vitest.config.ts b/src/problem5/vitest.config.ts new file mode 100644 index 0000000000..efb662d1b2 --- /dev/null +++ b/src/problem5/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + coverage: { + reporter: ['text', 'json', 'html'], + }, + }, +}); diff --git a/src/problem6/docs/SEQUENCES.md b/src/problem6/docs/SEQUENCES.md new file mode 100644 index 0000000000..6b1794739b --- /dev/null +++ b/src/problem6/docs/SEQUENCES.md @@ -0,0 +1,327 @@ +# Sequence Diagrams + +All diagrams use Mermaid syntax and render natively in GitHub/GitLab. + +## 1. Score Update Flow + +The main write path for processing user actions and updating leaderboards. + +```mermaid +sequenceDiagram + participant C as Client + participant API as API Gateway + participant S as Score Service + participant K as Kafka + participant EP as Event Processor + participant R as Redis + participant CH as ClickHouse + + C->>API: POST /scores (JWT) + API->>API: Validate JWT + Rate Limit + API->>S: Forward request + S->>S: Validate action with business logic + S->>K: Publish event (round-robin) + S-->>C: 202 Accepted (event_id) + + Note over K: Event persisted to disk + + K->>EP: Consume event + EP->>R: Check idempotency (Redis EXISTS) + R-->>EP: Not exists + EP->>R: Update 3 sorted sets (last_hour, today, alltime) + EP->>R: Mark event as processed (24h TTL) + EP->>CH: Insert into score_events table + EP->>CH: Update materialized view + EP-->>K: Acknowledge event +``` + +**Key Points:** +- Asynchronous processing (client doesn't wait for Redis/ClickHouse) +- Single service processes events to both Redis and ClickHouse +- Idempotency prevents duplicate processing +- Round-robin partitioning ensures even load distribution + +--- + +## 2. Leaderboard Query Flow + +Read path for retrieving leaderboard rankings. + +```mermaid +sequenceDiagram + participant C as Client + participant API as API Gateway + participant LS as Leaderboard Service + participant R as Redis + + C->>API: GET /leaderboard?window=today&limit=10 + API->>API: Validate JWT + API->>LS: Forward request + LS->>R: ZREVRANGE leaderboard:today 0 9 WITHSCORES + R-->>LS: Return top 10 (encoded scores) + LS->>LS: Decode scores (extract rank, timestamp) + LS->>LS: Fetch usernames from user service + LS-->>API: Rankings with metadata + API-->>C: 200 OK + rankings + freshness indicator +``` + +**Key Points:** +- Query time < 5ms (Redis sorted set operation) +- Score decoding extracts original score and timestamp +- Freshness indicator shows data age (e.g., "2 seconds old") +- No database queries (pure cache read) + +--- + +## 3. Live Update Flow (SSE) + +Real-time push updates to connected clients with horizontal scaling and selective broadcasting. + +```mermaid +sequenceDiagram + participant C1 as Client 1 (Viewing) + participant C2 as Client 2 (Not Viewing) + participant C3 as Client 3 (Viewing) + participant LB as Load Balancer + participant LS1 as Leaderboard Service 1 + participant LS2 as Leaderboard Service 2 + participant Redis as Redis Pub/Sub + + C1->>LB: GET /stream?window=today&viewing=true + LB->>LS1: Route (sticky session) + LS1->>Redis: HSET user:1:state viewing today + LS1-->>C1: SSE connection established + LS1->>Redis: SUBSCRIBE leaderboard:today:updates + + C2->>LB: GET /stream?window=today&viewing=false + LB->>LS1: Route (sticky session) + LS1->>Redis: HSET user:2:state viewing false + LS1-->>C2: SSE connection established + + C3->>LB: GET /stream?window=today&viewing=true + LB->>LS2: Route (different instance) + LS2->>Redis: HSET user:3:state viewing today + LS2-->>C3: SSE connection established + LS2->>Redis: SUBSCRIBE leaderboard:today:updates + + Note over LS1,LS2: Leaderboard changes (user 5 score update) + LS1->>Redis: PUBLISH leaderboard:today:updates + Redis->>LS1: Message received + Redis->>LS2: Message received + + LS1->>Redis: HGETALL user:*:state (get viewing users) + Redis-->>LS1: [user:1: viewing, user:3: viewing] + + LS1->>LS1: Fetch latest top 10 from Redis + LS1->>LS1: Check which users' rank changed + + LS1-->>C1: event: leaderboard_update (full top 10) + Note over C2: No update (not viewing) + + LS2->>Redis: HGETALL user:*:state + Redis-->>LS2: [user:1: viewing, user:3: viewing] + LS2->>LS2: Fetch latest top 10 from Redis + LS2-->>C3: event: rank_update (your rank: 5 → 4) +``` + +**Key Points:** +- **Horizontal scaling**: Multiple Leaderboard Service instances behind load balancer +- **Sticky sessions**: Clients stay on same instance (session affinity) +- **Redis pub/sub**: All instances receive updates simultaneously +- **Selective broadcasting**: Only active viewers receive updates (90% reduction) +- **Immediate updates**: No batching delay (100-200ms latency) +- **Targeted updates**: Users whose rank changed receive personalized `rank_update` +- **Each instance**: Handles ~10K connections independently + +--- + +## 4. Redis Failure Recovery Flow + +Disaster recovery when Redis fails and needs to be rebuilt. + +```mermaid +sequenceDiagram + participant M as Monitor + participant R as Redis + participant K as Kafka + participant CH as ClickHouse + participant EP as Event Processor + + Note over R: Redis fails (connection refused) + M->>M: Health check fails + M->>M: Alert: "Redis unavailable" + M->>R: Attempt restart + R-->>M: Health check passes + + alt Recovery within 7 days + M->>K: Reset consumer group offset (7 days ago) + K->>EP: Replay events from Kafka + EP->>EP: Process events (idempotency check) + EP->>R: Rebuild sorted sets + EP-->>M: Recovery complete + else Recovery older than 7 days + M->>CH: Query all-time scores (materialized view) + CH-->>M: Return aggregated data + M->>R: Rebuild alltime sorted set + M->>CH: Query today's scores + CH-->>M: Return today's data + M->>R: Rebuild today sorted set + M-->>M: Recovery complete + end + + M->>M: Validate recovery (compare counts) + M->>M: Resume normal operations +``` + +**Key Points:** +- Automatic detection and alerting +- Two recovery strategies based on data age +- Kafka replay for recent data (< 7 days) +- ClickHouse query for older data (> 7 days) +- Validation ensures data consistency + +--- + +## 5. Client Reconnection Flow (SSE Storm Prevention) + +Exponential backoff prevents connection storm when Redis recovers. + +```mermaid +sequenceDiagram + participant C as Client + participant LS as Leaderboard Service + participant LB as Load Balancer + + Note over LS: Redis recovers, all clients reconnect + + C->>LB: Connection attempt 1 + LB->>LS: Route request + LS-->>C: 503 Service Unavailable + Note over C: Wait 1s (exponential backoff) + + C->>LB: Connection attempt 2 + LB->>LS: Route request + LS-->>C: 503 Service Unavailable + Note over C: Wait 2s + + C->>LB: Connection attempt 3 + LB->>LS: Route request + LS-->>C: 503 Service Unavailable + Note over C: Wait 4s + Start polling fallback + + loop Polling every 5 seconds + C->>LB: GET /leaderboard?window=today + LB->>LS: Route request + LS-->>C: 200 OK (rankings) + end + + C->>LB: Connection attempt 4 + LB->>LS: Route request + LS-->>C: 200 OK (SSE connection established) + Note over C: Stop polling, resume SSE +``` + +**Key Points:** +- Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, 60s (max) +- Polling fallback after 3 failed attempts +- Prevents DDoS-like reconnection storm +- Graceful degradation (polling → SSE) + +--- + +## 6. Batching Flow (Future Improvement) + +> **Status: Future Improvement** - Not implemented yet. +> To be added if message rate becomes unsustainable (50K+ TPS). + +Events grouped into 1-second batches to reduce message rate. + +```mermaid +sequenceDiagram + participant C as Client + participant LS as Leaderboard Service + participant EP as Event Processor + participant R as Redis + + Note over EP: Multiple events in 1-second window + EP->>R: ZADD leaderboard:today 500 user:123 + EP->>R: PUBLISH leaderboard:today:updates + R->>LS: Pub/sub message received + LS->>LS: Add to batch queue + + EP->>R: ZADD leaderboard:today 600 user:456 + EP->>R: PUBLISH leaderboard:today:updates + R->>LS: Pub/sub message received + LS->>LS: Add to batch queue + + EP->>R: ZADD leaderboard:today 700 user:789 + EP->>R: PUBLISH leaderboard:today:updates + R->>LS: Pub/sub message received + LS->>LS: Add to batch queue + + Note over LS: 1-second timer fires + LS->>LS: Process batch (3 events) + LS->>LS: Fetch latest top 10 from Redis + LS->>LS: Serialize JSON update + LS-->>C: event: leaderboard_update (covers 3 changes) + + Note over C: Single update covers all 3 changes (1-2s delay) +``` + +**Key Points:** +- Events grouped into 1-second batches +- Single update covers multiple changes +- Update latency: 1-2 seconds (acceptable for leaderboards) +- Reduces message rate by 50-80% (10K → 2-5K updates/sec) +- Tradeoff: Slightly delayed updates (users wait up to 1 second) +- Implementation: Timer fires every 1 second, processes batch queue + +--- + +## 7. Priority Tiers Flow (Future Improvement) + +> **Status: Future Improvement** - Not implemented yet. +> To be added when concurrent users exceed 100K. + +Higher-ranked users receive more frequent updates. + +```mermaid +sequenceDiagram + participant C1 as Client (Rank 5) + participant C2 as Client (Rank 500) + participant C3 as Client (Rank 5000) + participant LS as Leaderboard Service + + C1->>LS: GET /stream?window=today&rank=5 + LS->>LS: Assign Tier 1 (rank 1-100): 1s updates + LS-->>C1: SSE connection (Tier 1) + + C2->>LS: GET /stream?window=today&rank=500 + LS->>LS: Assign Tier 2 (rank 101-1000): 2s updates + LS-->>C2: SSE connection (Tier 2) + + C3->>LS: GET /stream?window=today&rank=5000 + LS->>LS: Assign Tier 3 (rank 1000+): 5s updates + LS-->>C3: SSE connection (Tier 3) + + Note over LS: Leaderboard changes + LS->>LS: Add to tier batch queues + + Note over LS: 1 second later (Tier 1 timer) + LS-->>C1: event: leaderboard_update (1s update) + + Note over LS: 2 seconds later (Tier 2 timer) + LS-->>C2: event: leaderboard_update (2s update) + + Note over LS: 5 seconds later (Tier 3 timer) + LS-->>C3: event: leaderboard_update (5s update) +``` + +**Key Points:** +- Tier 1 (rank 1-100): 1-second updates (best experience) +- Tier 2 (rank 101-1000): 2-second updates (good experience) +- Tier 3 (rank 1000+): 5-second updates (acceptable experience) +- Reduces overall load by 50-70% +- Top users get best experience (they care most) +- Tradeoff: Lower-ranked users get less frequent updates +- Tier assignment updated when rank changes significantly diff --git a/src/problem6/docs/SERVICES.md b/src/problem6/docs/SERVICES.md new file mode 100644 index 0000000000..36b1e9c7b4 --- /dev/null +++ b/src/problem6/docs/SERVICES.md @@ -0,0 +1,78 @@ +# Service Architecture Summary + +## Overview + +The leaderboard system consists of **3 microservices** and **4 infrastructure components**. + +## Microservices + +### Service 1: Score Update Service +- **Purpose**: Handle score submissions from clients +- **Responsibilities**: + - Validate JWT authentication + - Validate action with business logic + - Generate unique event_id + - Publish events to Kafka + - Return 202 Accepted immediately +- **Scaling**: 3 instances (stateless, behind load balancer) + +### Service 2: Event Processor Service +- **Purpose**: Process events from Kafka and update both Redis and ClickHouse +- **Responsibilities**: + - Consume events from Kafka + - Check idempotency (skip duplicates) + - Update 3 Redis sorted sets (last_hour, today, alltime) + - Insert events into ClickHouse score_events table + - Update ClickHouse materialized views + - Handle TTL and window resets +- **Scaling**: 3 instances (one per Kafka partition group) + +### Service 3: Leaderboard Service +- **Purpose**: Serve leaderboard queries and push live updates +- **Responsibilities**: + - Read from Redis sorted sets + - Decode scores (extract rank, timestamp) + - Fetch user profiles + - Return rankings with freshness indicator (HTTP API) + - Maintain persistent SSE connections + - Subscribe to Redis pub/sub + - Selective broadcasting: only notify active viewers + - Push leaderboard and rank updates to clients +- **Scaling**: 10 instances (each handles ~10K connections) + +## Infrastructure Components + +### 1. API Gateway / Load Balancer +- JWT validation +- Rate limiting (per-user + global) +- SSL termination +- Request routing + +### 2. Kafka Cluster +- 3 brokers +- 12 partitions for score-events topic +- 7-day retention +- Replication factor: 3 + +### 3. Redis +- Single instance (upgrade to cluster at 50K TPS) +- 3 sorted sets for leaderboards +- Idempotency keys +- Pub/Sub for live update fan-out + +### 4. ClickHouse +- Single node (add shards at 50K TPS) +- MergeTree engine for score_events +- Materialized view for all-time aggregation + +## Service Communication + +``` +Client → API Gateway → Score Update Service → Kafka + ↓ + Event Processor Service + ↓ ↓ + Redis ClickHouse + ↓ + Leaderboard Service → Client +``` \ No newline at end of file diff --git a/src/problem6/docs/SYSTEM_DESIGN.md b/src/problem6/docs/SYSTEM_DESIGN.md new file mode 100644 index 0000000000..c89bc5de76 --- /dev/null +++ b/src/problem6/docs/SYSTEM_DESIGN.md @@ -0,0 +1,1006 @@ +# System Design + +## 1. Overview + +### Problem Statement +Design a real-time leaderboard service that displays the top 10 users across multiple time windows with live updates. The system must handle high-throughput score updates while maintaining data consistency and providing sub-100ms query latency. + +### Key Requirements + +**Functional Requirements:** +- Display top 10 users across three time windows: Last hour, Today, All-time +- Live updates via Server-Sent Events (SSE) +- Query user's current rank in any time window +- Server-side validation prevents unauthorized score updates +- Rate limiting: 100 actions/minute per user, 10K TPS global + +**Non-Functional Requirements:** +- Scale: 1M MAU, 100K DAU, 10K concurrent peak users +- Throughput: 10K TPS sustained write load +- Latency: < 100ms for leaderboard queries +- Freshness: Near real-time updates (within 1-2 seconds) +- Durability: Events persisted for 90 days for recovery and analytics +- Availability: 99.9% uptime target + +### Scale Assumptions +- 1M MAU users (100K DAU, 10K concurrent peak) +- 10K TPS sustained write throughput +- 3 time windows: Last hour, Today, All-time +- Top 10 leaderboard (extensible to 100) +- Score updates are additive only +- Server validates all actions before processing +- Events retained for 90 days in ClickHouse + +### Tech Stack Summary + +| Component | Technology | Purpose | +|-----------|-----------|---------| +| Event Bus | Kafka | Durable event streaming, source of truth | +| Cache | Redis | Live leaderboards (sorted sets) | +| Analytics DB | ClickHouse | Long-term event storage, aggregations | +| Live Updates | Server-Sent Events | Real-time push to clients | +| Authentication | JWT | Stateless user authentication | + +## 2. Component Architecture + +### Services Overview + +We have **3 microservices** in the system: + +1. **Score Update Service** - Handles score submissions and publishes to Kafka +2. **Event Processor Service** - Consumes events from Kafka and updates both Redis and ClickHouse +3. **Leaderboard Service** - Serves leaderboard queries (HTTP) and pushes live updates (SSE) + +Plus infrastructure components: API Gateway/Load Balancer, Kafka Cluster, Redis, ClickHouse. + +### Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ CLIENTS │ +│ (Web/Mobile - 1M MAU) │ +└────────────────┬────────────────────────────────────────────────────────┘ + │ + │ 1. POST /scores (JWT auth) + │ 2. GET /leaderboard (HTTP) + │ 3. GET /leaderboard/stream (SSE) + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ API GATEWAY / LOAD BALANCER │ +│ - JWT validation │ +│ - Rate limiting (per-user + global) │ +└────────────────┬────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ SERVICE 1: SCORE UPDATE SERVICE │ +│ - Validates action with business logic │ +│ - Writes score event to Kafka │ +│ - Returns 202 Accepted immediately │ +└────────────────┬────────────────────────────────────────────────────────┘ + │ + │ 4. Publish to Kafka (round-robin partitioning) + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ KAFKA CLUSTER │ +│ Topic: score-events │ +│ Partitions: 12 (scaled to throughput) │ +│ Retention: 7 days │ +└────────────────┬────────────────────────────────────────────────────────┘ + │ + │ 5. Consume events + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ SERVICE 2: EVENT PROCESSOR SERVICE │ +│ - Consumes events from Kafka │ +│ - Checks idempotency (skip duplicates) │ +│ - Updates Redis sorted sets (last_hour, today, alltime) │ +│ - Inserts into ClickHouse for long-term storage │ +│ - Handles TTL and window resets │ +└────────────────┬────────────────────────────────────────────────────────┘ + │ + ┌────────┴────────┐ + │ │ + ▼ ▼ +┌──────────────┐ ┌──────────────┐ +│ REDIS │ │ CLICKHOUSE │ +│ (3 sorted │ │ (Event │ +│ sets) │ │ storage) │ +└──────┬───────┘ └──────────────┘ + │ + │ 6. Query / Subscribe + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ SERVICE 3: LEADERBOARD SERVICE │ +│ - Reads from Redis sorted sets │ +│ - Returns top 10 or user rank (HTTP API) │ +│ - Maintains persistent SSE connections │ +│ - Subscribes to Redis pub/sub for live changes │ +│ - Pushes leaderboard & rank updates to clients (SSE) │ +│ - Selective broadcasting: only notify active viewers │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Component Responsibilities + +**Infrastructure Components:** + +**API Gateway / Load Balancer:** +- JWT validation on every request +- Rate limiting (per-user + global) +- SSL termination +- Request routing to appropriate services + +**Kafka Cluster:** +- Durable event streaming +- Fan-out to multiple consumers +- 7-day retention for recovery + +**Redis:** +- Live leaderboards (3 sorted sets) +- Idempotency keys +- Pub/Sub for SSE fan-out + +**ClickHouse:** +- Long-term event storage +- Materialized views for aggregations + +**Microservices:** + +**Service 1: Score Update Service** +- Action validation with business logic +- Event generation and publishing to Kafka +- Asynchronous response (202 Accepted) + +**Service 2: Event Processor Service** +- Consumes events from Kafka +- Idempotency checking (skip duplicates) +- Updates Redis sorted sets for all time windows +- Inserts events into ClickHouse for long-term storage +- Handles TTL and window resets + +**Service 3: Leaderboard Service** +- Read from Redis sorted sets +- Return top 10 or user rank (HTTP API) +- Maintain persistent SSE connections +- Subscribe to Redis pub/sub for live changes +- Push leaderboard and rank updates to clients (SSE) +- Selective broadcasting: only notify active viewers (5.8) + +### Future: 100K User Optimization Architecture + +When concurrent users exceed 100K, the following optimization layers are added on top of the base architecture: + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ OPTIMIZATION LAYERS │ +│ │ +│ Layer 1: SSE Transport (5.4) │ +│ └─ Foundation: Server-Sent Events for real-time push │ +│ │ +│ Layer 2: Selective Broadcasting (5.8) │ +│ └─ Track user viewing state in Redis │ +│ └─ Only send updates to active viewers + affected users │ +│ └─ Reduces messages by 90% (100K → 10K per update) │ +│ │ +│ Layer 3: Batching (10.1) - FUTURE (if needed) │ +│ └─ Group updates into 1-second windows │ +│ └─ Reduces message rate by 50-80% │ +│ └─ Tradeoff: 1-second delay (acceptable for leaderboards) │ +│ │ +│ Layer 4: Priority Tiers (10.2) - FUTURE (if needed) │ +│ └─ Top 100 users: 1-second updates │ +│ └─ Rank 101-1000: 2-second updates │ +│ └─ Rank 1000+: 5-second updates │ +│ └─ Reduces load by 50-70% │ +└─────────────────────────────────────────────────────────────────────────┘ + +Current Implementation (10K TPS, 100K concurrent users): + SSE + Selective Broadcasting = 10K messages/sec (sustainable) + +Future (50K+ TPS, 500K+ concurrent users): + SSE + Selective Broadcasting + Batching + Priority Tiers + = 5K messages/sec (sustainable) +``` + +**Key Changes for 100K Users:** + +1. **Selective Broadcasting (5.8)**: Track which users are actively viewing leaderboard + - Redis hash: `HSET user:123:state viewing today` + - Only send `leaderboard_update` to active viewers + - Send `rank_update` only to users whose rank changed + +2. **Event Batching (10.1)**: Future optimization + - Group updates into 1-second windows + - Reduces message rate by 50-80% + - Tradeoff: 1-second delay (acceptable for leaderboards) + +3. **Priority Tiers (10.2)**: Future optimization + - Higher-ranked users get more frequent updates + - Reduces overall load while satisfying top users + +## 3. Data Schema + +### 3.1 Redis Data Model + +**Sorted Sets (Leaderboards):** + +``` +Key: leaderboard:last_hour +Type: Sorted Set +Members: user_id +Scores: encoded score (score + timestamp for tie-breaking) +TTL: 3600 seconds (1 hour) + +Key: leaderboard:today +Type: Sorted Set +Members: user_id +Scores: encoded score +Reset: Daily at midnight UTC (cron job) + +Key: leaderboard:alltime +Type: Sorted Set +Members: user_id +Scores: encoded score +``` + +### 3.2 ClickHouse Schema + +**Event Storage Table:** + +```sql +CREATE TABLE score_events ( + timestamp DateTime, + user_id UInt32, + action_type String, + score_delta Int32, + event_id String, + metadata String +) ENGINE = MergeTree() +PARTITION BY toYYYYMM(timestamp) +ORDER BY (user_id, timestamp); +``` + +**Materialized View (All-Time Aggregation):** + +```sql +CREATE MATERIALIZED VIEW alltime_scores +ENGINE = AggregatingMergeTree() +ORDER BY user_id +AS SELECT + user_id, + sumState(score_delta) AS total_score +FROM score_events +GROUP BY user_id; +``` + +**Query Examples:** + +```sql +-- Top 10 today +SELECT user_id, SUM(score_delta) as total_score +FROM score_events +WHERE timestamp >= today() +GROUP BY user_id +ORDER BY total_score DESC +LIMIT 10; + +-- All-time top 10 (from materialized view) +SELECT user_id, sumMerge(total_score) AS score +FROM alltime_scores +GROUP BY user_id +ORDER BY score DESC +LIMIT 10; +``` + +### 3.3 Kafka Topic Schema + +**Topic Configuration:** + +``` +Topic: score-events +Partitions: 12 (scaled to handle 10K TPS) +Partitioning: Round-robin (even distribution, no hot partitions) +Retention: 7 days (allows Redis recovery from Kafka replay) +Replication factor: 3 (fault tolerance) +``` + +**Event Schema:** + +```json +{ + "event_id": "abc-123-def-456", + "user_id": 123, + "action_type": "level_complete", + "score_delta": 100, + "event_timestamp": "2024-01-15T10:30:00Z", + "metadata": { + "level_id": 42, + "completion_time_ms": 12345 + } +} +``` + +## 4. API Specification + +### 4.1 Score Update API + +**Endpoint:** `POST /api/v1/scores` + +**Request:** + +``` +Headers: + Authorization: Bearer + Content-Type: application/json + +Body: +{ + "action_type": "level_complete", + "score_delta": 100, + "event_timestamp": "2024-01-15T10:30:00Z", + "metadata": { + "level_id": 42, + "completion_time_ms": 12345 + } +} +``` + +**Response:** + +``` +202 Accepted +{ + "status": "accepted", + "event_id": "abc-123-def-456" +} +``` + +**Error Responses:** + +| Status Code | Description | +|-------------|-------------| +| 401 | Invalid or expired JWT | +| 429 | Rate limit exceeded | +| 400 | Invalid action or score_delta | +| 503 | Kafka unavailable | + +### 4.2 Leaderboard Query API + +**Get Leaderboard:** + +``` +GET /api/v1/leaderboard?window={hour|today|alltime}&limit=10&offset=0 + +Response: +{ + "window": "today", + "rankings": [ + {"user_id": 123, "username": "player1", "score": 5000, "rank": 1}, + {"user_id": 456, "username": "player2", "score": 4800, "rank": 2} + ], + "total_count": 100000, + "metadata": { + "last_updated": "2024-01-15T10:30:45Z", + "data_age_seconds": 2 + } +} +``` + +**Get User Rank:** + +``` +GET /api/v1/leaderboard/rank?window=today&user_id=789 + +Response: +{ + "user_id": 789, + "username": "player789", + "score": 1500, + "rank": 42, + "window": "today" +} +``` + +### 4.3 SSE Stream API + +**Endpoint:** `GET /api/v1/leaderboard/stream?window=today&user_id=789` + +**SSE Event Format:** + +``` +event: leaderboard_update +data: {"window": "today", "rankings": [...]} + +event: rank_update +data: {"user_id": 789, "rank": 41, "score": 1550} +``` + +**Connection Management:** +- Persistent HTTP connection +- Auto-reconnect with exponential backoff (1s, 2s, 4s, 8s, 16s, 32s, 60s) +- Fallback to polling after 3 failed attempts (every 5 seconds) + +## 5. Design Decisions + +### 5.1 Event Bus: Kafka vs Redis Streams + +**Decision:** Kafka + +**Assumptions:** +- 10K TPS sustained write throughput required +- Events must be durable and replayable for disaster recovery +- Multiple consumers need independent access to same events +- Team has Kafka expertise or can acquire it + +**Problem:** Need durable, high-throughput event streaming with replay capability for disaster recovery and multiple consumer fan-out. + +**Analysis:** + +**Why Kafka:** +- **Durability**: Events persisted to disk, replicated across brokers +- **Replayability**: 7-day retention allows Redis recovery by replaying events +- **Horizontal scaling**: 12 partitions can handle 10K+ TPS, easily scalable to 48+ partitions +- **Fan-out pattern**: Multiple consumer groups (Redis, ClickHouse, Analytics) can independently process same events +- **Backpressure handling**: Kafka buffers events when consumers are slow, preventing data loss + +**How it works:** +1. Score Update Service publishes events to Kafka topic `score-events` (12 partitions, round-robin) +2. Event Processor Service consumes from Kafka (3 consumer instances) +3. Events retained for 7 days (allows Redis rebuild if needed) +4. Multiple consumer groups can process same events independently + +**Benefits:** +- Handles 10K+ TPS with room to scale to 100K+ TPS +- 7-day retention enables disaster recovery +- Fan-out pattern supports multiple downstream services +- Proven at scale (LinkedIn, Uber, Netflix) + +**Tradeoffs accepted:** +- **Operational complexity**: Kafka requires 3+ brokers, ZooKeeper/KRaft, monitoring +- **Eventual consistency**: Kafka's at-least-once delivery requires idempotency handling +- **Learning curve**: Team needs Kafka expertise (partitions, consumer groups, offsets) + +### 5.2 Cache Layer: Redis Sorted Sets vs Database Indexes + +**Decision:** Redis Sorted Sets + +**Assumptions:** +- Sub-5ms query latency required for leaderboard reads +- Top-K queries (top 10) are the most common read pattern +- 1M MAU users, ~200MB RAM acceptable for sorted sets +- Team familiar with Redis operations and monitoring + +**Problem:** Need fast leaderboard queries (top 10, user rank) with sub-5ms latency at 10K TPS read load. + +**Analysis:** + +**Why Redis:** +- **Performance**: O(log N) updates, O(K) top-K queries, in-memory speed (< 5ms latency) +- **Purpose-built**: Sorted Sets are designed for leaderboards (ZADD, ZREVRANGE, ZREVRANK) +- **Simplicity**: Single command for top-K (`ZREVRANGE leaderboard:today 0 9 WITHSCORES`) +- **TTL support**: Built-in expiration for "Last hour" window (3600 seconds) +- **Pub/Sub**: Enables SSE fan-out pattern (all SSE servers receive updates) + +**How it works:** +1. Three Redis sorted sets: `leaderboard:last_hour`, `leaderboard:today`, `leaderboard:alltime` +2. Event Processor updates sorted sets via `ZADD` command (O(log N)) +3. Leaderboard Service queries via `ZREVRANGE` (O(K) for top K) +4. Score encoding includes timestamp for tie-breaking (first-to-reach wins) +5. TTL on `last_hour` set (3600 seconds) for automatic cleanup + +**Benefits:** +- Sub-5ms query latency at 10K+ TPS +- Purpose-built data structure for leaderboards +- Built-in TTL for time window management +- Pub/Sub enables real-time SSE updates + +**Tradeoffs accepted:** +- **Memory cost**: 1M users × 3 sorted sets = ~200MB RAM (acceptable at current scale) +- **Single-node limitations**: Redis is single-node (can scale with Redis Cluster at 50K+ TPS) +- **Data loss risk**: Redis failure requires recovery from Kafka/ClickHouse (mitigated by idempotency) + +### 5.3 Analytics Database: ClickHouse vs PostgreSQL+TimescaleDB + +**Decision:** ClickHouse + +**Assumptions:** +- Long-term event storage required (90 days retention) +- Analytical queries (SUM, GROUP BY) must be fast (< 1 second) +- Columnar storage provides better compression for time-series data +- Team can learn ClickHouse syntax and operations + +**Problem:** Need long-term event storage with fast analytical queries for all-time leaderboard aggregation and historical analysis. + +**Analysis:** + +**Why ClickHouse:** +- **Columnar storage**: 10-100x compression ratio (30TB → 3TB), reduces storage costs +- **Aggregation performance**: 10-100x faster than PostgreSQL for analytical queries (SUM, GROUP BY) +- **Materialized views**: Pre-aggregate scores per user, query time O(users) not O(events) +- **Horizontal scaling**: Shard by user_id, handle 100K+ TPS with 5-node cluster +- **Time-series optimized**: Built-in functions for time-based aggregations (toYYYYMM, time_bucket) + +**How it works:** +1. Event Processor inserts events into `score_events` table (MergeTree engine) +2. Materialized view `alltime_scores` automatically aggregates total score per user +3. Partitioned by month (toYYYYMM) for efficient queries and data lifecycle management +4. Queries use materialized view for O(users) instead of O(events) +5. Data compressed 10-100x (columnar storage) + +**Benefits:** +- 10-100x faster analytical queries than row-based databases +- 10-100x better compression (30TB → 3TB storage) +- Materialized views enable fast all-time leaderboard queries +- Horizontal scaling with sharding + +**Tradeoffs accepted:** +- **Learning curve**: ClickHouse syntax is different from PostgreSQL, team needs training +- **Operational complexity**: ClickHouse requires cluster management, monitoring, backups +- **Less familiar**: Fewer developers have ClickHouse experience compared to PostgreSQL + +### 5.4 Live Updates: SSE vs WebSockets + +**Decision:** Server-Sent Events (SSE) + +**Assumptions:** +- Unidirectional push (server→client) is sufficient for leaderboard updates +- Clients don't need to send data through the live update channel +- HTTP-based infrastructure (load balancers, proxies) must support the protocol +- Auto-reconnection is valuable for unstable mobile networks + +**Problem:** Need real-time push updates to clients when leaderboard changes, with automatic reconnection handling. + +**Analysis:** + +**Why SSE:** +- **Unidirectional**: Server→client push is sufficient (clients don't send data through SSE channel) +- **HTTP-based**: Works with existing HTTP infrastructure (load balancers, proxies, CORS) +- **Auto-reconnect**: Built-in reconnection handling (browser automatically reconnects on failure) +- **Simplicity**: Standard HTTP protocol, easy to debug, no custom framing +- **Browser support**: All modern browsers support SSE (EventSource API) + +**How it works:** +1. Client opens SSE connection: `GET /api/v1/leaderboard/stream?window=today` +2. Leaderboard Service maintains persistent HTTP connection +3. Service subscribes to Redis pub/sub channel `leaderboard:today:updates` +4. When leaderboard changes, Service pushes update to all connected clients +5. Browser automatically reconnects on connection failure (exponential backoff) + +**Benefits:** +- Works with existing HTTP infrastructure (no special proxy config) +- Built-in auto-reconnect (better mobile experience) +- Simpler than WebSockets (standard HTTP, easy to debug) +- Scales horizontally with Redis pub/sub fan-out + +**Tradeoffs accepted:** +- **HTTP/1.1 connection limits**: Browsers limit 6 connections per domain (mitigated by HTTP/2) +- **Browser support**: IE11 doesn't support SSE (requires polyfill, but IE11 is EOL) +- **Unidirectional limitation**: Cannot send data from client to server (but we don't need to) + +**Hierarchical optimization for 100K users:** + +SSE is the transport foundation. At 100K concurrent users, additional optimization layers are built on top of SSE: +- **5.8 Selective Broadcasting**: Controls WHO gets updates (only relevant users) - **Implemented** +- **10.1 Event Batching**: Controls WHEN to send (group updates to reduce frequency) - **Future** +- **10.2 Priority Tiers**: Controls HOW OFTEN per user rank (higher rank = more frequent) - **Future** + +Each layer builds on SSE and can be implemented incrementally as user count grows. See Section 10 for future enhancements. + +### 5.5 All-Time Aggregation: Materialized View vs Raw Queries + +**Decision:** ClickHouse materialized view + +**Assumptions:** +- All-time leaderboard queries must be fast (< 1 second) +- Historical analysis requires access to raw events +- 1M+ users with billions of events over time +- ClickHouse materialized views are maintained automatically + +**Problem:** Need the source of truth and fast all-time leaderboard queries that scale as data grows from millions to billions of events. + +**Analysis:** + +**Why materialized view:** +- **Performance**: Pre-aggregated scores, query time O(users) not O(events) +- **Automatic maintenance**: ClickHouse maintains materialized view in background +- **Flexibility**: Can query raw events for historical analysis, materialized view for current rankings +- **Scalability**: Handles billions of events, query time remains constant as data grows + +**How it works:** +1. Materialized view `alltime_scores` automatically aggregates `SUM(score_delta)` per user +2. View updated in background as new events inserted into `score_events` table +3. Query uses `sumMerge(total_score)` to get pre-aggregated scores +4. Query time O(users) instead of O(events) - constant as data grows +5. Raw events still available for historical analysis + +**Benefits:** +- Query time O(users) instead of O(events) - constant as data grows +- Automatic maintenance by ClickHouse (no manual jobs) +- Flexible: can query raw events or pre-aggregated scores +- Scales to billions of events without performance degradation + +**Tradeoffs accepted:** +- **Storage overhead**: Materialized view requires extra storage (1M users × 16 bytes = 16MB, acceptable) +- **Background processing**: ClickHouse maintains materialized view in background, adds CPU overhead +- **Complexity**: Requires understanding of ClickHouse materialized views, team needs training + +### 5.6 SSE Scalability: Horizontal Scaling + Redis Pub/Sub + +**Decision:** Horizontal scaling with Redis pub/sub fan-out + +**Assumptions:** +- 10K concurrent connections per SSE server is achievable +- Load balancer supports sticky sessions (session affinity) +- Redis pub/sub has < 1ms latency +- Team can operate multiple SSE server instances + +**Problem:** Need to handle 100K+ concurrent SSE connections for live leaderboard updates. + +**Analysis:** + +**Why horizontal scaling:** +- **Capacity**: 10+ SSE servers handle 100K+ concurrent connections (10K connections per server) +- **Standard pattern**: Proven pattern for real-time systems at scale +- **Flexibility**: Can add/remove SSE servers based on load, auto-scaling friendly + +**Why Redis pub/sub:** +- **Fan-out pattern**: All SSE servers receive updates simultaneously +- **Simplicity**: Standard Redis feature, well-documented, easy to implement +- **Performance**: Redis pub/sub is fast (< 1ms latency), suitable for real-time updates + +**How it works:** +1. 10+ SSE server instances behind load balancer (sticky sessions) +2. Each server maintains up to 10K persistent SSE connections +3. All servers subscribe to Redis pub/sub channel `leaderboard:today:updates` +4. When leaderboard changes, Event Processor publishes to Redis pub/sub +5. All SSE servers receive message simultaneously +6. Each server broadcasts to its connected clients +7. Load balancer ensures client stays on same server (sticky session) + +**Benefits:** +- Handles 100K+ concurrent connections (10 servers × 10K each) +- Easy to scale horizontally (add more SSE servers) +- All servers receive updates simultaneously (Redis pub/sub) +- Proven pattern at scale (Twitch, YouTube Live) + +**Tradeoffs accepted:** +- **Infrastructure complexity**: Requires 10+ SSE servers, load balancer, Redis pub/sub +- **Sticky sessions**: Users must connect to same SSE server (requires session affinity in load balancer) +- **Operational overhead**: Monitoring, logging, debugging across multiple SSE servers + +### 5.8 Update Delivery: Selective Broadcasting vs Full Broadcasting + +**Decision:** Selective broadcasting (only notify relevant users) + +**Assumptions:** +- Most users don't need updates when they're not actively viewing the leaderboard +- Users care more about their own rank changes than global leaderboard changes +- Tracking user viewing state is feasible +- 90% of bandwidth can be saved by only notifying active viewers + +**Problem:** Broadcasting full leaderboard updates to all 100K connected users wastes bandwidth and CPU when most users aren't actively viewing. + +**Analysis:** + +**Why selective broadcasting:** +- **Bandwidth reduction**: 90% fewer messages (only notify active viewers + affected users) +- **CPU efficiency**: SSE servers serialize JSON for fewer connections +- **Relevance**: Users only receive updates they care about (own rank, leaderboard they're viewing) +- **Scalability**: Reduces load on SSE servers, allows handling more concurrent users + +**How it works:** +1. Client opens SSE connection with viewing context: `GET /stream?window=today&viewing=true` +2. Leaderboard Service tracks viewing state in Redis: `HSET user:123:state viewing today` +3. When leaderboard changes, Event Processor publishes to Redis pub/sub +4. Leaderboard Service receives update, checks which users are viewing +5. Service sends `leaderboard_update` only to active viewers +6. Service sends `rank_update` only to users whose rank changed +7. User closes leaderboard view: `HDEL user:123:state viewing` + +**Benefits:** +- 90% reduction in messages sent (100K → 10K per update) +- 80% reduction in bandwidth (100MB/sec → 20MB/sec) +- Better user experience (only relevant updates) +- Enables handling 100K+ concurrent users + +**Tradeoffs accepted:** +- **State tracking overhead**: Requires tracking viewing state in Redis (small memory cost) +- **Complexity**: More complex than simple broadcast (need to check viewing state) +- **Delayed updates**: Users not actively viewing don't get real-time updates (acceptable) + +**Dependency:** Builds on SSE transport (5.4) and horizontal scaling (5.6). + +## 6. Security Considerations + +### Authentication +- **JWT tokens**: Stateless, scalable, 15-minute expiration with refresh tokens +- **API Gateway validation**: JWT validated on every request +- **Token storage**: Tokens stored in HTTP-only cookies or localStorage (XSS protection) + +### Authorization +- **Server-side validation**: All score updates validated by business logic +- **No direct client writes**: Clients cannot directly modify Redis or database +- **Action verification**: Business logic verifies user completed the action before awarding points + +### Rate Limiting +- **Per-user**: 100 actions/minute (prevents individual abuse) +- **Global**: 10K TPS (protects system capacity) +- **Implementation**: Redis-based sliding window counter +- **Response**: 429 Too Many Requests when exceeded + +### Data Validation +- **Score delta**: Must be positive integer, max 10000 +- **Action type**: Whitelist of valid action types +- **Metadata**: JSON schema validation +- **Timestamp**: Must be within 5 seconds of server time (prevents timestamp manipulation) + +## 7. Scalability Strategy + +### Current Capacity (10K TPS) +- **Redis**: Single instance (handles 100K+ ops/sec) +- **Kafka**: 12 partitions, 3 consumers +- **ClickHouse**: Single node (handles 100K+ inserts/sec) + +### Scaling to 50K TPS +- **Redis**: Add Redis Cluster (sharding by user_id) +- **Kafka**: Increase partitions to 24, add more consumers +- **ClickHouse**: Add 2-3 nodes with sharding + +### Scaling to 100K TPS +- **Redis**: 3-node cluster +- **Kafka**: 48 partitions, 6+ consumers +- **ClickHouse**: 5-node cluster with replication + +## 8. Monitoring and Alerting + +### Key Metrics +- **Write latency**: P95 < 100ms (API → Kafka) +- **Read latency**: P95 < 50ms (Redis queries) +- **Kafka lag**: Consumer lag < 1000 events +- **Redis memory**: < 80% of allocated memory +- **ClickHouse storage**: Growth rate < 1 TB/month +- **SSE connections**: Active connections, reconnection rate +- **Error rate**: < 0.1% for all endpoints + +### Alerts +- **Kafka consumer lag**: > 10000 events +- **Redis memory**: > 90% +- **ClickHouse disk usage**: > 80% +- **API error rate**: > 1% +- **SSE connection count**: Drops > 50% + +## 9. Constraints and Assumptions + +### Constraints +- 1M MAU users (100K DAU, 10K concurrent peak) +- 10K TPS sustained write throughput +- 3 time windows: Last hour, Today, All-time +- Top 10 leaderboard (extensible to 100) +- Score updates are additive only (no decrements) +- Server validates all actions before processing +- Events retained for 90 days in ClickHouse +- Network latency < 100ms between services + +### Assumptions +- Users can perform multiple actions per second +- Score deltas are positive integers +- Action validation takes < 50ms +- Kafka write latency < 10ms +- Redis query latency < 5ms +- ClickHouse insert latency < 20ms +- SSE connections are stable (auto-reconnect on failure) +- Clock synchronization across services (NTP) + +### Out of Scope +- Arbitrary time windows (e.g., "last 3 days") +- Pagination beyond top 100 +- Historical leaderboard queries (e.g., "top 10 last week") +- Multi-region deployment +- GDPR data deletion workflows + +## 10. Future Improvements + +> **Status: Future Enhancements** - These optimizations are not required for initial implementation. +> To be implemented when the system scales beyond current capacity or when specific user experience improvements are needed. + +### 10.1 Event Batching + +**Solution:** Implement 1-second batching window for leaderboard updates + +**Problem it solves:** At high throughput (50K+ TPS), sending every event immediately creates unsustainable load on SSE servers and network bandwidth. + +**How it works:** +1. Event Processor updates Redis sorted set +2. Event Processor publishes to Redis pub/sub +3. Leaderboard Service receives message, adds to batch queue +4. Timer fires every 1 second, processes batch +5. Service fetches latest leaderboard from Redis +6. Service sends single update covering all changes in last second +7. Client receives batched update within 1-2 seconds + +**Benefits:** +- 50-80% reduction in message rate (10K → 2-5K updates/sec) +- Lower bandwidth consumption +- Better scalability for high-throughput scenarios +- 1-2 second delay is acceptable for leaderboards + +**When to implement:** +- Message rate exceeds 50K TPS +- Bandwidth costs become unsustainable +- SSE servers reach CPU limits + +**Dependency:** Builds on SSE transport (5.4) and selective broadcasting (5.8). + +### 10.2 Priority-based Connection Tiers + +**Solution:** Implement tiered update frequency based on user rank + +**Problem it solves:** At 100K+ concurrent users, uniform update frequency creates unsustainable load even with selective broadcasting and batching. + +**How it works:** +1. Client connects with tier based on current rank: `GET /stream?window=today&rank=42` +2. Leaderboard Service assigns tier: + - Tier 1 (rank 1-100): 1-second updates + - Tier 2 (rank 101-1000): 2-second updates + - Tier 3 (rank 1000+): 5-second updates +3. Service maintains separate batch queues per tier +4. Timer fires per tier (1s, 2s, 5s), processes batch +5. Service sends update to users in that tier +6. User's tier updated when rank changes significantly + +**Benefits:** +- 50-70% reduction in overall message load +- Top-ranked users (who care most) get best experience +- Enables handling 500K+ concurrent users +- Perceived as fair (users who invested time get better service) + +**When to implement:** +- Concurrent users exceed 100K +- Top-ranked users request more frequent updates +- Need to scale to 500K+ users + +**Tradeoffs:** +- Lower-ranked users get less frequent updates +- Requires tier assignment and tracking logic +- Must update user's tier when rank changes significantly + +**Dependency:** Builds on SSE transport (5.4), selective broadcasting (5.8), and batching (10.1). + +### 10.3 Distributed Tracing & Observability + +**Solution:** Implement comprehensive observability stack + +**Implementation:** +- OpenTelemetry for end-to-end request tracing +- Track latency breakdown: API → Kafka → Redis → ClickHouse +- Structured logging with correlation IDs +- Grafana dashboards for system health monitoring + +**Benefits:** +- Faster debugging and troubleshooting +- Performance optimization insights +- SLA monitoring and compliance +- Proactive issue detection + +### 10.4 Graceful Degradation + +**Solution:** Implement circuit breakers and fallback mechanisms + +**Implementation:** +- Circuit breakers for Redis/ClickHouse failures +- Fallback to stale data (serve cached leaderboard) when services unavailable +- Automatic recovery when services restored + +**Benefits:** +- Higher availability during partial outages +- Better user experience when dependencies fail +- Reduced impact of infrastructure issues + +### 10.5 Cost Optimization + +**Solution:** Implement storage and memory optimization strategies + +**Implementation:** +- Redis memory optimization (use hash encoding for small sorted sets) +- ClickHouse data compression policies (compress data older than 30 days) +- Auto-scaling based on actual load patterns + +**Benefits:** +- 20-30% reduction in infrastructure costs +- More efficient resource utilization +- Better cost-to-performance ratio + +## 11. Testing Strategy + +**Unit Tests:** + +- **Score Update Service:** + - Test action validation logic + - Test event generation + - Test Kafka publishing (mock Kafka) + - Test error handling (invalid input, Kafka unavailable) + +- **Event Processor Service:** + - Test idempotency checking + - Test score encoding/decoding + - Test Redis sorted set updates + - Test ClickHouse event insertion + - Test materialized view updates + - Test TTL handling + - Test error handling + +- **Leaderboard Service:** + - Test top-K queries + - Test user rank queries + - Test score decoding + - Test freshness indicator + - Test SSE connection management + - Test selective broadcasting logic + - Test pub/sub message handling + - Test user-specific updates + +**Integration Tests:** + +- **End-to-end flow:** + - Submit score update → verify Redis and ClickHouse updated + - Query leaderboard → verify correct rankings + - SSE connection → verify live updates received + +- **Failure scenarios:** + - Kafka unavailable → API returns 503 + - Redis unavailable → recovery procedure works + - ClickHouse unavailable → events buffered in Kafka + +- **Concurrency:** + - Multiple concurrent score updates → no data loss + - Multiple concurrent queries → consistent results + - SSE reconnection → no missed updates + +**Load Tests:** + +- **Tools:** k6, JMeter, or custom load generator + +- **Scenarios:** + - **Baseline:** 1K TPS for 10 minutes + - **Peak:** 10K TPS for 30 minutes + - **Stress:** 20K TPS for 15 minutes (identify breaking point) + - **Endurance:** 5K TPS for 2 hours (check for memory leaks) + +- **Metrics to monitor:** + - Request latency (P50, P95, P99) + - Error rate (should be < 0.1%) + - Kafka consumer lag (should be < 1000) + - Redis memory usage (should be < 80%) + - ClickHouse insert rate (should match write rate) + - SSE connection count (should be stable) + +- **Acceptance criteria:** + - P95 latency < 100ms for API requests + - P95 latency < 50ms for leaderboard queries + - Error rate < 0.1% + - No data loss (all events processed) + - System recovers from failures automatically + +**Chaos Tests:** + +- **Tools:** Chaos Monkey, Gremlin, or custom scripts + +- **Scenarios:** + - Kill Redis → verify recovery procedure + - Kill Kafka broker → verify cluster continues + - Kill ClickHouse → verify events buffered in Kafka + - Network partition → verify system degrades gracefully + - High latency injection → verify timeouts work correctly + +- **Expected behavior:** + - System continues operating (degraded mode) + - Automatic recovery when component restored + - No data loss + - Alerts triggered appropriately + + diff --git a/src/problem6/readme.md b/src/problem6/readme.md new file mode 100644 index 0000000000..0b30f3b8a6 --- /dev/null +++ b/src/problem6/readme.md @@ -0,0 +1,47 @@ +# Real-Time Leaderboard Service + +A high-performance, real-time leaderboard system supporting 1M MAU with 10K TPS throughput and sub-100ms query latency. + +## Overview + +This system provides real-time leaderboards across multiple time windows (Last hour, Today, All-time) with live updates via Server-Sent Events. It handles high-throughput score updates while maintaining data consistency and providing fast query responses. + +**Key Features:** +- Top 10 leaderboards across 3 time windows +- Live updates via SSE with selective broadcasting (only active viewers) +- Immediate updates (no batching delay) +- Server-side action validation +- Rate limiting (100 actions/min per user) +- 99.9% availability target +- Scalable to 100K+ concurrent users + +## Documentation + +- [System Design](docs/SYSTEM_DESIGN.md) - Complete architecture, data models, APIs, and design decisions +- [Sequence Diagrams](docs/SEQUENCES.md) - Flow diagrams for key scenarios +- [Service Architecture](docs/SERVICES.md) - Microservices overview and scaling strategy + +## Tech Stack + +| Component | Technology | Purpose | +|-----------|-----------|---------| +| Event Bus | Kafka | Durable event streaming, source of truth | +| Cache | Redis | Live leaderboards (sorted sets) | +| Analytics DB | ClickHouse | Long-term event storage, aggregations | +| Live Updates | Server-Sent Events | Real-time push to clients | +| Authentication | JWT | Stateless user authentication | + +## Scale & Performance + +- **Users**: 1M MAU, 100K DAU, 10K concurrent peak +- **Throughput**: 10K TPS sustained writes +- **Latency**: < 100ms for queries, < 5ms for Redis operations +- **Storage**: ~30TB/month events (compressed to ~3TB with ClickHouse) + +## Quick Links + +- [Architecture Overview](docs/SYSTEM_DESIGN.md#2-component-architecture) +- [Data Models](docs/SYSTEM_DESIGN.md#3-data-schema) +- [API Reference](docs/SYSTEM_DESIGN.md#4-api-specification) +- [Design Decisions](docs/SYSTEM_DESIGN.md#5-design-decisions) +- [Flow Diagrams](docs/SEQUENCES.md) From 4f9084fb2c84374ff41c8459d756167a0ca1c72a Mon Sep 17 00:00:00 2001 From: chithuchcmus Date: Sun, 5 Jul 2026 11:13:05 +0700 Subject: [PATCH 2/2] feat: remove guide --- GUIDELINE.md | 212 --------------------------------------------------- 1 file changed, 212 deletions(-) delete mode 100644 GUIDELINE.md diff --git a/GUIDELINE.md b/GUIDELINE.md deleted file mode 100644 index be3cae2d69..0000000000 --- a/GUIDELINE.md +++ /dev/null @@ -1,212 +0,0 @@ -# 99Tech Code Challenge — Backend Guideline - -## 1. Context - -- **Challenge:** 99Tech Code Challenge #1 -- **Role:** Backend Engineer -- **Repository:** `99techteam/code-challenge` -- **Goal:** Implement solutions for 5 problems under `src/problem1/` through `src/problem5/` -- **Evaluation:** Correctness, code quality, architecture, TypeScript usage, error handling, test coverage, and creativity where constraints are open - -## 2. Tech Stack - -| Layer | Choice | -| ------------------ | ----------------------- | -| Runtime | Node.js (LTS) | -| Language | TypeScript (strict) | -| Framework | Express.js | -| Validation | Zod | -| Testing | Vitest + Supertest | -| Linting/Formatting | ESLint + Prettier | -| DB (if needed) | SQLite via better-sqlite3 (no external deps) | -| Logging | pino | - -## 3. Project Structure - -``` -code-challenge/ -├── src/ -│ ├── problem1/ # Independent problem -│ │ ├── index.ts # Router + public API -│ │ ├── controller/ # HTTP layer -│ │ ├── service/ # Business logic -│ │ ├── repository/ # Data access -│ │ ├── model/ # Domain types/interfaces -│ │ ├── middleware/ # Problem-specific middleware -│ │ ├── schema/ # Zod validation schemas -│ │ └── __tests__/ # Tests -│ │ -│ ├── problem2/ # Independent problem -│ ├── problem3/ # Independent problem -│ ├── problem4/ # Independent problem -│ └── problem5/ # Independent problem -│ -├── app.ts # Express app factory (for testing) -├── server.ts # Entry point — starts the server -├── package.json -├── tsconfig.json -├── vitest.config.ts -├── eslint.config.js -└── .prettierrc -``` - -## 4. Architecture Principles - -### 4.1 Layered Architecture (per problem) - -``` -Request → Router → Controller → Service → Repository → Data - ↓ ↓ - Validation Business Logic - (Zod) (Domain rules) -``` - -**Rules:** -- Controller: receives HTTP request, validates input, calls service, formats response -- Service: pure business logic, no HTTP awareness, no Express types -- Repository: data access only, no business logic -- No layer may skip or reverse-call another layer -- Each problem is **independent** — no imports between problems -- Each problem should have its own error handling, middleware, and utilities - -### 4.2 Error Handling - -- Custom `AppError` base class with `statusCode`, `message`, `code` -- Specific subclasses: `ValidationError`, `NotFoundError`, `ConflictError` -- Each problem has its own error middleware in `problem{n}/middleware/error-handler.ts` -- Errors thrown in services propagate up to the error middleware - -### 4.3 Response Format - -```typescript -// Success -{ "data": T, "meta"?: PaginationInfo } - -// Error (handled by error middleware) -{ "error": { "code": string, "message": string, "details"?: unknown } } -``` - -## 5. Coding Conventions - -### TypeScript -- **Strict mode** — `strict: true` in tsconfig -- **No `any`** — use `unknown` + type guards when type is uncertain -- **Explicit return types** on all exported functions -- **Readonly where possible** — `readonly` properties, prefer `const` -- **Named exports** over default exports -- **Interface** for object shapes, **type** for unions/intersections/utilities - -### Style -- Async/await — no raw `.then()` chains -- Descriptive names — `getUserById` not `getUser` -- Single responsibility per function (keep functions small) -- No `console.log` — use `pino` logger -- No unused imports or variables - -### Testing -- File naming: `*.test.ts` -- Unit tests for services (mock repositories) -- Integration tests for controllers (Supertest against Express app) -- Cover: happy path, edge cases, error paths -- Test naming: `describe('ServiceName')` → `it('should ...')` - -## 6. Dependency Flow - -Each problem is **completely independent** — no dependencies between problems. - -``` -problem1 (standalone) -problem2 (standalone) -problem3 (standalone) -problem4 (standalone) -problem5 (standalone) -``` - -**What this means:** -- Each problem has its own domain models, services, and utilities -- No problem imports from any other problem -- Each problem is self-contained with its own error handling, middleware, and types - -## 7. Per-Problem Workflow - -For each problem, the implementation order is: - -1. **Read & understand** the problem statement -2. **Define domain models** — types/interfaces in `model/` -3. **Define validation schemas** — Zod in `schema/` -4. **Implement repository** — data access layer -5. **Implement service** — business logic with unit tests -6. **Implement controller** — HTTP layer with integration tests -7. **Wire router** in `index.ts` -8. **Register** in `app.ts` -9. **Verify** — run lint + typecheck + tests - -## 8. Evaluation Criteria (what reviewers score on) - -| Criteria | What they look for | -| ------------------------- | ----------------------------------------------------- | -| Correctness | Does the solution produce the right output? | -| Architecture | Clean separation of concerns, layered design | -| TypeScript quality | Type safety, no `any`, proper generics | -| Error handling | Edge cases handled, meaningful error messages | -| Test coverage | Happy path + error paths tested | -| Code readability | Clean naming, small functions, well-organized | -| Creativity | Thoughtful design where requirements are open-ended | -| Documentation | README per problem explaining design decisions | - -## 9. Maximizing Evaluation Score - -### 9.1 General Principles -- **Read requirements thoroughly** — implement exactly what's asked, nothing more, nothing less -- **Follow the framework** — use the layered architecture, tech stack, and conventions defined in this document -- **Prioritize correctness** — working code beats clever code -- **Test everything** — happy paths, edge cases, error scenarios -- **Document decisions** — explain trade-offs and design choices in per-problem READMEs - -### 9.2 Problem-Type Strategy - -**Coding Problems** (algorithmic/data structure focused) -- Optimize for time/space complexity as specified -- Handle edge cases explicitly (empty input, null, boundary values) -- Write clear, readable code with meaningful variable names -- Include unit tests covering all scenarios -- Add comments only for non-obvious logic - -**Function/Feature Problems** (API endpoints, business logic) -- Strict input validation with Zod schemas -- Proper HTTP status codes (200, 201, 400, 404, 409, 500) -- Consistent response format (`{ data, meta }` / `{ error }`) -- Integration tests with Supertest -- Error handling for all failure paths - -**Architecture Problems** (system design, refactoring) -- Clear separation of concerns (controller/service/repository) -- Dependency injection where appropriate -- Extensibility and maintainability -- Document architectural decisions -- Show trade-offs considered - -**Documentation Problems** (design docs, technical writing) -- Clear structure with headings, diagrams, examples -- Cover: requirements, design decisions, trade-offs, future improvements -- Use diagrams (Mermaid/ASCII) for architecture/flows -- Keep it concise but comprehensive - -### 9.3 Quality Checklist Before Submission -- [ ] All requirements implemented and tested -- [ ] Lint passes with zero warnings -- [ ] TypeScript strict mode, no `any` types -- [ ] Test coverage: happy path + error paths -- [ ] README explains design decisions -- [ ] No hardcoded secrets or credentials -- [ ] Code follows project conventions - -## 10. Assumptions & Notes - -- Problem statements will be provided per-problem in future sessions -- Problems are **independent** — each is self-contained with no dependencies on other problems -- Each problem should have its own error handling, middleware, and utilities (no shared `common/` directory) -- In-memory data storage is acceptable unless a problem specifies otherwise -- SQLite is available if persistence is needed (no external DB setup required) -- The `app.ts` exports the Express app (without listening) for testability -- The `server.ts` imports `app.ts` and starts the HTTP listener