Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
1d9c451dfb
|
|||
|
3197498ab3
|
|||
|
a1a2c2747c
|
|||
|
80e7e7939c
|
|||
|
6caa1850e3
|
|||
|
7aaac65af4
|
|||
|
13e0c81957
|
17
CHANGELOG.md
17
CHANGELOG.md
@@ -2,8 +2,25 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file. Dates are displayed in UTC.
|
All notable changes to this project will be documented in this file. Dates are displayed in UTC.
|
||||||
|
|
||||||
|
#### [1.8.2](https://git.odit.services/lfk/backend/compare/1.8.1...1.8.2)
|
||||||
|
|
||||||
|
- chore(PERFORMANCE_IDEAS): Remove outdated performance optimization ideas document [`a1a2c27`](https://git.odit.services/lfk/backend/commit/a1a2c2747cda8ad4c049d0d3b188e993daa67a01)
|
||||||
|
- refactor(Dockerfile): Update build process and entry point for TypeScript application [`3197498`](https://git.odit.services/lfk/backend/commit/3197498ab37221156eef42311e58a4038c3309d1)
|
||||||
|
- fix(deps): Add @types/bun dependency to devDependencies [`6caa185`](https://git.odit.services/lfk/backend/commit/6caa1850e3668bbf72912121d2d1923a5e22d6e8)
|
||||||
|
- fix(CreateUser): Await password hashing in toEntity method [`80e7e79`](https://git.odit.services/lfk/backend/commit/80e7e7939c1ab35da9ece1cd9e6e1002e4e50d3a)
|
||||||
|
|
||||||
|
#### [1.8.1](https://git.odit.services/lfk/backend/compare/1.8.0...1.8.1)
|
||||||
|
|
||||||
|
> 20 February 2026
|
||||||
|
|
||||||
|
- perf(stats): Cache stats results for 60 seconds [`13e0c81`](https://git.odit.services/lfk/backend/commit/13e0c81957768c1b380914a0b93d3617c60e08a0)
|
||||||
|
- chore(release): 1.8.1 [`7aaac65`](https://git.odit.services/lfk/backend/commit/7aaac65af4e2d04653645adcf859ca69449e2332)
|
||||||
|
|
||||||
#### [1.8.0](https://git.odit.services/lfk/backend/compare/1.7.2...1.8.0)
|
#### [1.8.0](https://git.odit.services/lfk/backend/compare/1.7.2...1.8.0)
|
||||||
|
|
||||||
|
> 20 February 2026
|
||||||
|
|
||||||
|
- chore(release): 1.8.0 [`329a29a`](https://git.odit.services/lfk/backend/commit/329a29aca70b8c779c592149dc1cfe197ab62463)
|
||||||
- refactor: Switch from official argon2 to Bun's implementation [`a1e697a`](https://git.odit.services/lfk/backend/commit/a1e697acb264a753534c5ff8f5f43357cbc287da)
|
- refactor: Switch from official argon2 to Bun's implementation [`a1e697a`](https://git.odit.services/lfk/backend/commit/a1e697acb264a753534c5ff8f5f43357cbc287da)
|
||||||
- refactor: Replace uuid and dotenv with bun primitives [`abce517`](https://git.odit.services/lfk/backend/commit/abce517d86daa00d76d691081907cb832494cb91)
|
- refactor: Replace uuid and dotenv with bun primitives [`abce517`](https://git.odit.services/lfk/backend/commit/abce517d86daa00d76d691081907cb832494cb91)
|
||||||
- refactor(deps): Remove unused glob dependency from package.json and bun.lock [`abdadb8`](https://git.odit.services/lfk/backend/commit/abdadb8e6419c5ec9f8cc0a9e5ebf68671d84a94)
|
- refactor(deps): Remove unused glob dependency from package.json and bun.lock [`abdadb8`](https://git.odit.services/lfk/backend/commit/abdadb8e6419c5ec9f8cc0a9e5ebf68671d84a94)
|
||||||
|
|||||||
20
Dockerfile
20
Dockerfile
@@ -1,23 +1,23 @@
|
|||||||
# Typescript Build
|
# Build stage - install dependencies
|
||||||
FROM registry.odit.services/hub/oven/bun:1.3.9-alpine AS build
|
FROM registry.odit.services/hub/oven/bun:1.3.9-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json bun.lockb* ./
|
COPY package.json bun.lockb* ./
|
||||||
RUN bun install --frozen-lockfile
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
COPY tsconfig.json ormconfig.js bunfig.toml ./
|
# Production dependencies only
|
||||||
COPY src ./src
|
RUN rm -rf /app/node_modules \
|
||||||
RUN bun run build \
|
|
||||||
&& rm -rf /app/node_modules \
|
|
||||||
&& bun install --production --frozen-lockfile
|
&& bun install --production --frozen-lockfile
|
||||||
|
|
||||||
# final image
|
# Final image - run TypeScript directly
|
||||||
FROM registry.odit.services/hub/oven/bun:1.3.9-alpine AS final
|
FROM registry.odit.services/hub/oven/bun:1.3.9-alpine AS final
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=build /app/package.json /app/package.json
|
COPY --from=build /app/package.json /app/package.json
|
||||||
COPY --from=build /app/bun.lockb* /app/
|
COPY --from=build /app/bun.lockb* /app/
|
||||||
COPY --from=build /app/ormconfig.js /app/ormconfig.js
|
|
||||||
COPY --from=build /app/bunfig.toml /app/bunfig.toml
|
|
||||||
COPY --from=build /app/dist /app/dist
|
|
||||||
COPY --from=build /app/node_modules /app/node_modules
|
COPY --from=build /app/node_modules /app/node_modules
|
||||||
ENTRYPOINT ["bun", "/app/dist/app.js"]
|
|
||||||
|
COPY ormconfig.js bunfig.toml tsconfig.json ./
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
ENTRYPOINT ["bun", "/app/src/app.ts"]
|
||||||
@@ -1,589 +0,0 @@
|
|||||||
# Performance Optimization Ideas for LfK Backend
|
|
||||||
|
|
||||||
This document outlines potential performance improvements for the LfK backend API, organized by impact and complexity.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Already Implemented
|
|
||||||
|
|
||||||
### 1. Bun Runtime Migration
|
|
||||||
**Status**: Complete
|
|
||||||
**Impact**: 8-15% latency improvement
|
|
||||||
**Details**: Migrated from Node.js to Bun runtime, achieving:
|
|
||||||
- Parallel throughput: +8.3% (306 → 331 scans/sec)
|
|
||||||
- Parallel p50 latency: -9.5% (21ms → 19ms)
|
|
||||||
|
|
||||||
### 2. NATS KV Cache for Scan Intake
|
|
||||||
**Status**: Complete (based on code analysis)
|
|
||||||
**Impact**: Significant reduction in DB reads for hot path
|
|
||||||
**Details**: `ScanController.stationIntake()` uses NATS JetStream KV store to cache:
|
|
||||||
- Station tokens (1-hour TTL)
|
|
||||||
- Card→Runner mappings (1-hour TTL)
|
|
||||||
- Runner state (no TTL, CAS-based updates)
|
|
||||||
- Eliminates DB reads on cache hits
|
|
||||||
- Prevents race conditions via compare-and-swap (CAS)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 High Impact, Low-Medium Complexity
|
|
||||||
|
|
||||||
### 3. Add Database Indexes
|
|
||||||
**Priority**: HIGH
|
|
||||||
**Complexity**: Low
|
|
||||||
**Estimated Impact**: 30-70% query time reduction
|
|
||||||
|
|
||||||
**Problem**: TypeORM synchronize() doesn't automatically create indexes on foreign keys or commonly queried fields.
|
|
||||||
|
|
||||||
**Observations**:
|
|
||||||
- Heavy use of `find()` with complex nested relations (e.g., `['runner', 'track', 'runner.scans', 'runner.group', 'runner.scans.track']`)
|
|
||||||
- No explicit `@Index()` decorators found in entity files
|
|
||||||
- Frequent filtering by foreign keys (runner_id, track_id, station_id, card_id)
|
|
||||||
|
|
||||||
**Recommended Indexes**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/models/entities/Scan.ts
|
|
||||||
@Index(['runner', 'timestamp']) // For runner scan history queries
|
|
||||||
@Index(['station', 'timestamp']) // For station-based queries
|
|
||||||
@Index(['card']) // For card lookup
|
|
||||||
|
|
||||||
// src/models/entities/Runner.ts
|
|
||||||
@Index(['email']) // For authentication/lookup
|
|
||||||
@Index(['group']) // For group-based queries
|
|
||||||
|
|
||||||
// src/models/entities/RunnerCard.ts
|
|
||||||
@Index(['runner']) // For card→runner lookups
|
|
||||||
@Index(['code']) // For barcode scans
|
|
||||||
|
|
||||||
// src/models/entities/Donation.ts
|
|
||||||
@Index(['runner']) // For runner donations
|
|
||||||
@Index(['donor']) // For donor contributions
|
|
||||||
```
|
|
||||||
|
|
||||||
**Implementation Steps**:
|
|
||||||
1. Audit all entities and add `@Index()` decorators
|
|
||||||
2. Test query performance with `EXPLAIN` before/after
|
|
||||||
3. Monitor index usage with database tools
|
|
||||||
4. Consider composite indexes for frequently combined filters
|
|
||||||
|
|
||||||
**Expected Results**:
|
|
||||||
- 50-70% faster JOIN operations
|
|
||||||
- 30-50% faster foreign key lookups
|
|
||||||
- Reduced database CPU usage
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. Implement Query Result Caching
|
|
||||||
**Priority**: HIGH
|
|
||||||
**Complexity**: Medium
|
|
||||||
**Estimated Impact**: 50-90% latency reduction for repeated queries
|
|
||||||
|
|
||||||
**Problem**: Stats endpoints and frequently accessed data (org totals, team rankings, runner lists) are recalculated on every request.
|
|
||||||
|
|
||||||
**Observations**:
|
|
||||||
- `StatsController` methods load entire datasets with deep relations:
|
|
||||||
- `getRunnerStats()`: loads all runners with scans, groups, donations
|
|
||||||
- `getTeamStats()`: loads all teams with nested runner data
|
|
||||||
- `getOrgStats()`: loads all orgs with teams, runners, scans
|
|
||||||
- Many `find()` calls without any caching layer
|
|
||||||
- Data changes infrequently (only during scan intake)
|
|
||||||
|
|
||||||
**Solution Options**:
|
|
||||||
|
|
||||||
**Option A: NATS KV Cache (Recommended)**
|
|
||||||
```typescript
|
|
||||||
// src/nats/StatsKV.ts
|
|
||||||
export async function getOrgStatsCache(): Promise<ResponseOrgStats[] | null> {
|
|
||||||
const kv = await NatsClient.getKV('stats_cache', { ttl: 60 * 1000 }); // 60s TTL
|
|
||||||
const entry = await kv.get('org_stats');
|
|
||||||
return entry ? JSON.parse(entry.string()) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setOrgStatsCache(stats: ResponseOrgStats[]): Promise<void> {
|
|
||||||
const kv = await NatsClient.getKV('stats_cache', { ttl: 60 * 1000 });
|
|
||||||
await kv.put('org_stats', JSON.stringify(stats));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invalidate on scan creation
|
|
||||||
// src/controllers/ScanController.ts (after line 173)
|
|
||||||
await invalidateStatsCache(); // Clear stats on new scan
|
|
||||||
```
|
|
||||||
|
|
||||||
**Option B: In-Memory Cache with TTL**
|
|
||||||
```typescript
|
|
||||||
// src/cache/MemoryCache.ts
|
|
||||||
import NodeCache from 'node-cache';
|
|
||||||
|
|
||||||
const cache = new NodeCache({ stdTTL: 60 }); // 60s TTL
|
|
||||||
|
|
||||||
export function getCached<T>(key: string): T | undefined {
|
|
||||||
return cache.get<T>(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setCached<T>(key: string, value: T, ttl?: number): void {
|
|
||||||
cache.set(key, value, ttl);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function invalidatePattern(pattern: string): void {
|
|
||||||
const keys = cache.keys().filter(k => k.includes(pattern));
|
|
||||||
cache.del(keys);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Option C: Redis Cache** (if Redis is already in stack)
|
|
||||||
|
|
||||||
**Recommended Cache Strategy**:
|
|
||||||
- **TTL**: 30-60 seconds for stats endpoints
|
|
||||||
- **Invalidation**: On scan creation, runner updates, donation changes
|
|
||||||
- **Keys**: `stats:org`, `stats:team:${id}`, `stats:runner:${id}`
|
|
||||||
- **Warm on startup**: Pre-populate cache for critical endpoints
|
|
||||||
|
|
||||||
**Expected Results**:
|
|
||||||
- 80-90% latency reduction for stats endpoints (from ~500ms to ~50ms)
|
|
||||||
- 70-80% reduction in database load
|
|
||||||
- Improved user experience for dashboards and leaderboards
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. Lazy Load Relations & DTOs
|
|
||||||
**Priority**: HIGH
|
|
||||||
**Complexity**: Medium
|
|
||||||
**Estimated Impact**: 40-60% query time reduction
|
|
||||||
|
|
||||||
**Problem**: Many queries eagerly load deeply nested relations that aren't always needed.
|
|
||||||
|
|
||||||
**Observations**:
|
|
||||||
```typescript
|
|
||||||
// Current: Loads everything
|
|
||||||
scan = await this.scanRepository.findOne(
|
|
||||||
{ id: scan.id },
|
|
||||||
{ relations: ['runner', 'track', 'runner.scans', 'runner.group',
|
|
||||||
'runner.scans.track', 'card', 'station'] }
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
**A. Create Lightweight Response DTOs**
|
|
||||||
```typescript
|
|
||||||
// src/models/responses/ResponseScanLight.ts
|
|
||||||
export class ResponseScanLight {
|
|
||||||
@IsInt() id: number;
|
|
||||||
@IsInt() distance: number;
|
|
||||||
@IsInt() timestamp: number;
|
|
||||||
@IsBoolean() valid: boolean;
|
|
||||||
// Omit nested runner.scans, runner.group, etc.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use for list views
|
|
||||||
@Get()
|
|
||||||
@ResponseSchema(ResponseScanLight, { isArray: true })
|
|
||||||
async getAll() {
|
|
||||||
const scans = await this.scanRepository.find({
|
|
||||||
relations: ['runner', 'track'] // Minimal relations
|
|
||||||
});
|
|
||||||
return scans.map(s => new ResponseScanLight(s));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep detailed DTO for single-item views
|
|
||||||
@Get('/:id')
|
|
||||||
@ResponseSchema(ResponseScan) // Full details
|
|
||||||
async getOne(@Param('id') id: number) { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
**B. Use Query Builder for Selective Loading**
|
|
||||||
```typescript
|
|
||||||
// Instead of loading all scans with runner relations:
|
|
||||||
const scans = await this.scanRepository
|
|
||||||
.createQueryBuilder('scan')
|
|
||||||
.leftJoinAndSelect('scan.runner', 'runner')
|
|
||||||
.leftJoinAndSelect('scan.track', 'track')
|
|
||||||
.select([
|
|
||||||
'scan.id', 'scan.distance', 'scan.timestamp', 'scan.valid',
|
|
||||||
'runner.id', 'runner.firstname', 'runner.lastname',
|
|
||||||
'track.id', 'track.name'
|
|
||||||
])
|
|
||||||
.where('scan.id = :id', { id })
|
|
||||||
.getOne();
|
|
||||||
```
|
|
||||||
|
|
||||||
**C. Implement GraphQL-style Field Selection**
|
|
||||||
```typescript
|
|
||||||
@Get()
|
|
||||||
async getAll(@QueryParam('fields') fields?: string) {
|
|
||||||
const relations = [];
|
|
||||||
if (fields?.includes('runner')) relations.push('runner');
|
|
||||||
if (fields?.includes('track')) relations.push('track');
|
|
||||||
return this.scanRepository.find({ relations });
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected Results**:
|
|
||||||
- 40-60% faster list queries
|
|
||||||
- 50-70% reduction in data transfer size
|
|
||||||
- Reduced JOIN complexity and memory usage
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6. Pagination Optimization
|
|
||||||
**Priority**: MEDIUM
|
|
||||||
**Complexity**: Low
|
|
||||||
**Estimated Impact**: 20-40% improvement for large result sets
|
|
||||||
|
|
||||||
**Problem**: Current pagination uses `skip/take` which becomes slow with large offsets.
|
|
||||||
|
|
||||||
**Current Implementation**:
|
|
||||||
```typescript
|
|
||||||
// Inefficient for large page numbers (e.g., page=1000)
|
|
||||||
scans = await this.scanRepository.find({
|
|
||||||
skip: page * page_size, // Scans 100,000 rows to skip them
|
|
||||||
take: page_size
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
**A. Cursor-Based Pagination (Recommended)**
|
|
||||||
```typescript
|
|
||||||
@Get()
|
|
||||||
async getAll(
|
|
||||||
@QueryParam('cursor') cursor?: number, // Last ID from previous page
|
|
||||||
@QueryParam('page_size') page_size: number = 100
|
|
||||||
) {
|
|
||||||
const query = this.scanRepository.createQueryBuilder('scan')
|
|
||||||
.orderBy('scan.id', 'ASC')
|
|
||||||
.take(page_size + 1); // Get 1 extra to determine if more pages exist
|
|
||||||
|
|
||||||
if (cursor) {
|
|
||||||
query.where('scan.id > :cursor', { cursor });
|
|
||||||
}
|
|
||||||
|
|
||||||
const scans = await query.getMany();
|
|
||||||
const hasMore = scans.length > page_size;
|
|
||||||
const results = scans.slice(0, page_size);
|
|
||||||
const nextCursor = hasMore ? results[results.length - 1].id : null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: results.map(s => s.toResponse()),
|
|
||||||
pagination: { nextCursor, hasMore }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**B. Add Total Count Caching**
|
|
||||||
```typescript
|
|
||||||
// Cache total counts to avoid expensive COUNT(*) queries
|
|
||||||
const totalCache = new Map<string, { count: number, expires: number }>();
|
|
||||||
|
|
||||||
async function getTotalCount(repo: Repository<any>): Promise<number> {
|
|
||||||
const cacheKey = repo.metadata.tableName;
|
|
||||||
const cached = totalCache.get(cacheKey);
|
|
||||||
|
|
||||||
if (cached && cached.expires > Date.now()) {
|
|
||||||
return cached.count;
|
|
||||||
}
|
|
||||||
|
|
||||||
const count = await repo.count();
|
|
||||||
totalCache.set(cacheKey, { count, expires: Date.now() + 60000 }); // 60s TTL
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected Results**:
|
|
||||||
- 60-80% faster pagination for large page numbers
|
|
||||||
- Consistent query performance regardless of offset
|
|
||||||
- Better mobile app experience with cursor-based loading
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Medium Impact, Medium Complexity
|
|
||||||
|
|
||||||
### 7. Database Connection Pooling Optimization
|
|
||||||
**Priority**: MEDIUM
|
|
||||||
**Complexity**: Medium
|
|
||||||
**Estimated Impact**: 10-20% improvement under load
|
|
||||||
|
|
||||||
**Current**: Default TypeORM connection pooling (likely 10 connections)
|
|
||||||
|
|
||||||
**Recommendations**:
|
|
||||||
```typescript
|
|
||||||
// ormconfig.js
|
|
||||||
module.exports = {
|
|
||||||
// ... existing config
|
|
||||||
extra: {
|
|
||||||
// PostgreSQL specific
|
|
||||||
max: 20, // Max pool size (adjust based on load)
|
|
||||||
min: 5, // Min pool size
|
|
||||||
idleTimeoutMillis: 30000, // Close idle connections after 30s
|
|
||||||
connectionTimeoutMillis: 2000,
|
|
||||||
|
|
||||||
// MySQL specific
|
|
||||||
connectionLimit: 20,
|
|
||||||
waitForConnections: true,
|
|
||||||
queueLimit: 0
|
|
||||||
},
|
|
||||||
|
|
||||||
// Enable query logging in dev to identify slow queries
|
|
||||||
logging: process.env.NODE_ENV !== 'production' ? ['query', 'error'] : ['error'],
|
|
||||||
maxQueryExecutionTime: 1000, // Log queries taking >1s
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**Monitor**:
|
|
||||||
- Connection pool exhaustion
|
|
||||||
- Query execution times
|
|
||||||
- Active connection count
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 8. Bulk Operations for Import
|
|
||||||
**Priority**: MEDIUM
|
|
||||||
**Complexity**: Medium
|
|
||||||
**Estimated Impact**: 50-80% faster imports
|
|
||||||
|
|
||||||
**Problem**: Import endpoints likely save entities one-by-one in loops.
|
|
||||||
|
|
||||||
**Solution**:
|
|
||||||
```typescript
|
|
||||||
// Instead of:
|
|
||||||
for (const runnerData of importData) {
|
|
||||||
const runner = await createRunner.toEntity();
|
|
||||||
await this.runnerRepository.save(runner); // N queries
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use bulk insert:
|
|
||||||
const runners = await Promise.all(
|
|
||||||
importData.map(data => createRunner.toEntity())
|
|
||||||
);
|
|
||||||
await this.runnerRepository.save(runners); // 1 query
|
|
||||||
|
|
||||||
// Or use raw query for massive imports:
|
|
||||||
await getConnection()
|
|
||||||
.createQueryBuilder()
|
|
||||||
.insert()
|
|
||||||
.into(Runner)
|
|
||||||
.values(runners)
|
|
||||||
.execute();
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 9. Response Compression
|
|
||||||
**Priority**: MEDIUM
|
|
||||||
**Complexity**: Low
|
|
||||||
**Estimated Impact**: 60-80% reduction in response size
|
|
||||||
|
|
||||||
**Implementation**:
|
|
||||||
```typescript
|
|
||||||
// src/app.ts
|
|
||||||
import compression from 'compression';
|
|
||||||
|
|
||||||
const app = createExpressServer({ ... });
|
|
||||||
app.use(compression({
|
|
||||||
level: 6, // Compression level (1-9)
|
|
||||||
threshold: 1024, // Only compress responses >1KB
|
|
||||||
filter: (req, res) => {
|
|
||||||
if (req.headers['x-no-compression']) return false;
|
|
||||||
return compression.filter(req, res);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
```
|
|
||||||
|
|
||||||
**Benefits**:
|
|
||||||
- 70-80% smaller JSON responses
|
|
||||||
- Faster transfer times on slow networks
|
|
||||||
- Reduced bandwidth costs
|
|
||||||
|
|
||||||
**Dependencies**: `bun add compression @types/compression`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Lower Priority / High Complexity
|
|
||||||
|
|
||||||
### 10. Implement Read Replicas
|
|
||||||
**Priority**: LOW (requires infrastructure)
|
|
||||||
**Complexity**: High
|
|
||||||
**Estimated Impact**: 30-50% read query improvement
|
|
||||||
|
|
||||||
**When to Consider**:
|
|
||||||
- Database CPU consistently >70%
|
|
||||||
- Read-heavy workload (already true for stats endpoints)
|
|
||||||
- Running PostgreSQL/MySQL in production
|
|
||||||
|
|
||||||
**Implementation**:
|
|
||||||
```typescript
|
|
||||||
// ormconfig.js
|
|
||||||
module.exports = {
|
|
||||||
type: 'postgres',
|
|
||||||
replication: {
|
|
||||||
master: {
|
|
||||||
host: process.env.DB_WRITE_HOST,
|
|
||||||
port: 5432,
|
|
||||||
username: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
},
|
|
||||||
slaves: [
|
|
||||||
{
|
|
||||||
host: process.env.DB_READ_REPLICA_1,
|
|
||||||
port: 5432,
|
|
||||||
username: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 11. Move to Serverless/Edge Functions
|
|
||||||
**Priority**: LOW (architectural change)
|
|
||||||
**Complexity**: Very High
|
|
||||||
**Estimated Impact**: Variable (depends on workload)
|
|
||||||
|
|
||||||
**Considerations**:
|
|
||||||
- Good for: Infrequent workloads, global distribution
|
|
||||||
- Bad for: High-frequency scan intake (cold starts)
|
|
||||||
- May conflict with TypeORM's connection model
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 12. GraphQL API Layer
|
|
||||||
**Priority**: LOW (major refactor)
|
|
||||||
**Complexity**: Very High
|
|
||||||
**Estimated Impact**: 30-50% for complex queries
|
|
||||||
|
|
||||||
**Benefits**:
|
|
||||||
- Clients request only needed fields
|
|
||||||
- Single request for complex nested data
|
|
||||||
- Better mobile app performance
|
|
||||||
|
|
||||||
**Trade-offs**:
|
|
||||||
- Complete rewrite of controller layer
|
|
||||||
- Learning curve for frontend teams
|
|
||||||
- More complex caching strategy
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Recommended Implementation Order
|
|
||||||
|
|
||||||
**Phase 1: Quick Wins** (1-2 weeks)
|
|
||||||
1. Add database indexes → Controllers still work, immediate improvement
|
|
||||||
2. Enable response compression → One-line change in `app.ts`
|
|
||||||
3. Implement cursor-based pagination → Better mobile UX
|
|
||||||
|
|
||||||
**Phase 2: Caching Layer** (2-3 weeks)
|
|
||||||
4. Add NATS KV cache for stats endpoints
|
|
||||||
5. Create lightweight response DTOs for list views
|
|
||||||
6. Cache total counts for pagination
|
|
||||||
|
|
||||||
**Phase 3: Query Optimization** (2-3 weeks)
|
|
||||||
7. Refactor controllers to use query builder with selective loading
|
|
||||||
8. Optimize database connection pooling
|
|
||||||
9. Implement bulk operations for imports
|
|
||||||
|
|
||||||
**Phase 4: Infrastructure** (ongoing)
|
|
||||||
10. Monitor query performance and add more indexes as needed
|
|
||||||
11. Consider read replicas when database becomes bottleneck
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 Performance Monitoring Recommendations
|
|
||||||
|
|
||||||
### Add Metrics Endpoint
|
|
||||||
```typescript
|
|
||||||
// src/controllers/MetricsController.ts
|
|
||||||
import { performance } from 'perf_hooks';
|
|
||||||
|
|
||||||
const requestMetrics = {
|
|
||||||
totalRequests: 0,
|
|
||||||
avgLatency: 0,
|
|
||||||
p95Latency: 0,
|
|
||||||
dbQueryCount: 0,
|
|
||||||
cacheHitRate: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
@JsonController('/metrics')
|
|
||||||
export class MetricsController {
|
|
||||||
@Get()
|
|
||||||
@Authorized('ADMIN') // Restrict to admins
|
|
||||||
async getMetrics() {
|
|
||||||
return requestMetrics;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Enable Query Logging
|
|
||||||
```typescript
|
|
||||||
// ormconfig.js
|
|
||||||
logging: ['query', 'error'],
|
|
||||||
maxQueryExecutionTime: 1000, // Warn on queries >1s
|
|
||||||
```
|
|
||||||
|
|
||||||
### Add Request Timing Middleware
|
|
||||||
```typescript
|
|
||||||
// src/middlewares/TimingMiddleware.ts
|
|
||||||
export function timingMiddleware(req: Request, res: Response, next: NextFunction) {
|
|
||||||
const start = performance.now();
|
|
||||||
|
|
||||||
res.on('finish', () => {
|
|
||||||
const duration = performance.now() - start;
|
|
||||||
if (duration > 1000) {
|
|
||||||
consola.warn(`Slow request: ${req.method} ${req.path} took ${duration}ms`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Performance Testing Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run baseline benchmark
|
|
||||||
bun run benchmark > baseline.txt
|
|
||||||
|
|
||||||
# After implementing changes, compare
|
|
||||||
bun run benchmark > optimized.txt
|
|
||||||
diff baseline.txt optimized.txt
|
|
||||||
|
|
||||||
# Load testing with artillery (if added)
|
|
||||||
artillery quick --count 100 --num 10 http://localhost:4010/api/runners
|
|
||||||
|
|
||||||
# Database query profiling (PostgreSQL)
|
|
||||||
EXPLAIN ANALYZE SELECT * FROM scan WHERE runner_id = 1;
|
|
||||||
|
|
||||||
# Check database indexes
|
|
||||||
SELECT * FROM pg_indexes WHERE tablename = 'scan';
|
|
||||||
|
|
||||||
# Monitor NATS cache hit rate
|
|
||||||
# (Add custom logging in NATS KV functions)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎓 Key Principles
|
|
||||||
|
|
||||||
1. **Measure first**: Always benchmark before and after changes
|
|
||||||
2. **Start with indexes**: Biggest impact, lowest risk
|
|
||||||
3. **Cache strategically**: Stats endpoints benefit most
|
|
||||||
4. **Lazy load by default**: Only eager load when absolutely needed
|
|
||||||
5. **Monitor in production**: Use APM tools (New Relic, DataDog, etc.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 Additional Resources
|
|
||||||
|
|
||||||
- [TypeORM Performance Tips](https://typeorm.io/performance)
|
|
||||||
- [PostgreSQL Index Best Practices](https://www.postgresql.org/docs/current/indexes.html)
|
|
||||||
- [Bun Performance Benchmarks](https://bun.sh/docs/runtime/performance)
|
|
||||||
- [NATS JetStream KV Guide](https://docs.nats.io/nats-concepts/jetstream/key-value-store)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated**: 2026-02-20
|
|
||||||
**Status**: Ready for review and prioritization
|
|
||||||
5
bun.lock
5
bun.lock
@@ -34,6 +34,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@faker-js/faker": "7.6.0",
|
"@faker-js/faker": "7.6.0",
|
||||||
"@odit/license-exporter": "0.0.9",
|
"@odit/license-exporter": "0.0.9",
|
||||||
|
"@types/bun": "^1.3.9",
|
||||||
"@types/cors": "2.8.19",
|
"@types/cors": "2.8.19",
|
||||||
"@types/csvtojson": "1.1.5",
|
"@types/csvtojson": "1.1.5",
|
||||||
"@types/express": "5.0.6",
|
"@types/express": "5.0.6",
|
||||||
@@ -251,6 +252,8 @@
|
|||||||
|
|
||||||
"@types/body-parser": ["@types/body-parser@1.19.0", "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.0.tgz", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ=="],
|
"@types/body-parser": ["@types/body-parser@1.19.0", "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.0.tgz", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ=="],
|
||||||
|
|
||||||
|
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
||||||
|
|
||||||
"@types/cacheable-request": ["@types/cacheable-request@6.0.1", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "*", "@types/node": "*", "@types/responselike": "*" } }, "sha512-ykFq2zmBGOCbpIXtoVbz4SKY5QriWPh3AjyU4G74RYbtt5yOc5OfaY75ftjg7mikMOla1CTGpX3lLbuJh8DTrQ=="],
|
"@types/cacheable-request": ["@types/cacheable-request@6.0.1", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "*", "@types/node": "*", "@types/responselike": "*" } }, "sha512-ykFq2zmBGOCbpIXtoVbz4SKY5QriWPh3AjyU4G74RYbtt5yOc5OfaY75ftjg7mikMOla1CTGpX3lLbuJh8DTrQ=="],
|
||||||
|
|
||||||
"@types/connect": ["@types/connect@3.4.34", "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.34.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-ePPA/JuI+X0vb+gSWlPKOY0NdNAie/rPUqX2GUPpbZwiKTkSPhjXWuee47E4MtE54QVzGCQMQkAL6JhV2E1+cQ=="],
|
"@types/connect": ["@types/connect@3.4.34", "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.34.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-ePPA/JuI+X0vb+gSWlPKOY0NdNAie/rPUqX2GUPpbZwiKTkSPhjXWuee47E4MtE54QVzGCQMQkAL6JhV2E1+cQ=="],
|
||||||
@@ -435,6 +438,8 @@
|
|||||||
|
|
||||||
"buffer-writer": ["buffer-writer@2.0.0", "https://registry.yarnpkg.com/buffer-writer/-/buffer-writer-2.0.0.tgz", {}, "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw=="],
|
"buffer-writer": ["buffer-writer@2.0.0", "https://registry.yarnpkg.com/buffer-writer/-/buffer-writer-2.0.0.tgz", {}, "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||||
|
|
||||||
"busboy": ["busboy@0.2.14", "https://registry.yarnpkg.com/busboy/-/busboy-0.2.14.tgz", { "dependencies": { "dicer": "0.2.5", "readable-stream": "1.1.x" } }, "sha1-bCpiLvz0fFe7vh4qnDetNseSVFM="],
|
"busboy": ["busboy@0.2.14", "https://registry.yarnpkg.com/busboy/-/busboy-0.2.14.tgz", { "dependencies": { "dicer": "0.2.5", "readable-stream": "1.1.x" } }, "sha1-bCpiLvz0fFe7vh4qnDetNseSVFM="],
|
||||||
|
|
||||||
"bytes": ["bytes@3.1.0", "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz", {}, "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg=="],
|
"bytes": ["bytes@3.1.0", "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz", {}, "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg=="],
|
||||||
|
|||||||
@@ -4,3 +4,10 @@
|
|||||||
[runtime]
|
[runtime]
|
||||||
# Enable Node.js compatibility mode
|
# Enable Node.js compatibility mode
|
||||||
bun = true
|
bun = true
|
||||||
|
|
||||||
|
# TypeScript transpiler settings
|
||||||
|
# Required for TypeORM decorators
|
||||||
|
[transpiler]
|
||||||
|
tsconfig = "tsconfig.json"
|
||||||
|
emitDecoratorMetadata = true
|
||||||
|
experimentalDecorators = true
|
||||||
|
|||||||
29
licenses.md
29
licenses.md
@@ -942,6 +942,35 @@ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
|
|||||||
OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
|
||||||
|
# @types/bun
|
||||||
|
**Author**: undefined
|
||||||
|
**Repo**: [object Object]
|
||||||
|
**License**: MIT
|
||||||
|
**Description**: TypeScript definitions for bun
|
||||||
|
## License Text
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) Microsoft Corporation.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE
|
||||||
|
|
||||||
|
|
||||||
# @types/cors
|
# @types/cors
|
||||||
**Author**: undefined
|
**Author**: undefined
|
||||||
**Repo**: [object Object]
|
**Repo**: [object Object]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@odit/lfk-backend",
|
"name": "@odit/lfk-backend",
|
||||||
"version": "1.8.0",
|
"version": "1.8.2",
|
||||||
"main": "src/app.ts",
|
"main": "src/app.ts",
|
||||||
"repository": "https://git.odit.services/lfk/backend",
|
"repository": "https://git.odit.services/lfk/backend",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -51,6 +51,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@faker-js/faker": "7.6.0",
|
"@faker-js/faker": "7.6.0",
|
||||||
"@odit/license-exporter": "0.0.9",
|
"@odit/license-exporter": "0.0.9",
|
||||||
|
"@types/bun": "^1.3.9",
|
||||||
"@types/cors": "2.8.19",
|
"@types/cors": "2.8.19",
|
||||||
"@types/csvtojson": "1.1.5",
|
"@types/csvtojson": "1.1.5",
|
||||||
"@types/express": "5.0.6",
|
"@types/express": "5.0.6",
|
||||||
@@ -70,7 +71,6 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun --watch src/app.ts",
|
"dev": "bun --watch src/app.ts",
|
||||||
"start": "bun src/app.ts",
|
"start": "bun src/app.ts",
|
||||||
"build": "rimraf ./dist && tsc && cp-cli ./src/static ./dist/static",
|
|
||||||
"docs": "typedoc --out docs src",
|
"docs": "typedoc --out docs src",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watchAll",
|
"test:watch": "jest --watchAll",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { ResponseStats } from '../models/responses/ResponseStats';
|
|||||||
import { ResponseStatsOrgnisation } from '../models/responses/ResponseStatsOrganization';
|
import { ResponseStatsOrgnisation } from '../models/responses/ResponseStatsOrganization';
|
||||||
import { ResponseStatsRunner } from '../models/responses/ResponseStatsRunner';
|
import { ResponseStatsRunner } from '../models/responses/ResponseStatsRunner';
|
||||||
import { ResponseStatsTeam } from '../models/responses/ResponseStatsTeam';
|
import { ResponseStatsTeam } from '../models/responses/ResponseStatsTeam';
|
||||||
|
import { getStatsCache, setStatsCache } from '../nats/StatsKV';
|
||||||
|
|
||||||
@JsonController('/stats')
|
@JsonController('/stats')
|
||||||
export class StatsController {
|
export class StatsController {
|
||||||
@@ -22,6 +23,13 @@ export class StatsController {
|
|||||||
@ResponseSchema(ResponseStats)
|
@ResponseSchema(ResponseStats)
|
||||||
@OpenAPI({ description: "A very basic stats endpoint providing basic counters for a dashboard or simmilar" })
|
@OpenAPI({ description: "A very basic stats endpoint providing basic counters for a dashboard or simmilar" })
|
||||||
async get() {
|
async get() {
|
||||||
|
// Try cache first
|
||||||
|
const cached = await getStatsCache<ResponseStats>('overview');
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss - compute fresh stats
|
||||||
const connection = getConnection();
|
const connection = getConnection();
|
||||||
const runnersViaSelfservice = await connection.getRepository(Runner).count({ where: { created_via: "selfservice" } });
|
const runnersViaSelfservice = await connection.getRepository(Runner).count({ where: { created_via: "selfservice" } });
|
||||||
const runnersViaKiosk = await connection.getRepository(Runner).count({ where: { created_via: "kiosk" } });
|
const runnersViaKiosk = await connection.getRepository(Runner).count({ where: { created_via: "kiosk" } });
|
||||||
@@ -43,7 +51,12 @@ export class StatsController {
|
|||||||
let donations = await connection.getRepository(Donation).find({ relations: ['runner', 'runner.scans', 'runner.scans.track'] });
|
let donations = await connection.getRepository(Donation).find({ relations: ['runner', 'runner.scans', 'runner.scans.track'] });
|
||||||
const donors = await connection.getRepository(Donor).count();
|
const donors = await connection.getRepository(Donor).count();
|
||||||
|
|
||||||
return new ResponseStats(runnersViaSelfservice, runners, teams, orgs, users, scans, donations, distace, donors, runnersViaKiosk)
|
const result = new ResponseStats(runnersViaSelfservice, runners, teams, orgs, users, scans, donations, distace, donors, runnersViaKiosk);
|
||||||
|
|
||||||
|
// Store in cache for 60 seconds
|
||||||
|
await setStatsCache('overview', result);
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("/runners/distance")
|
@Get("/runners/distance")
|
||||||
@@ -51,6 +64,13 @@ export class StatsController {
|
|||||||
@ResponseSchema(ResponseStatsRunner, { isArray: true })
|
@ResponseSchema(ResponseStatsRunner, { isArray: true })
|
||||||
@OpenAPI({ description: "Returns the top ten runners by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
@OpenAPI({ description: "Returns the top ten runners by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
||||||
async getTopRunnersByDistance() {
|
async getTopRunnersByDistance() {
|
||||||
|
// Try cache first
|
||||||
|
const cached = await getStatsCache<ResponseStatsRunner[]>('runners.distance');
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss - compute fresh stats
|
||||||
let runners = await getConnection().getRepository(Runner).find({ relations: ['scans', 'group', 'distanceDonations', 'scans.track'] });
|
let runners = await getConnection().getRepository(Runner).find({ relations: ['scans', 'group', 'distanceDonations', 'scans.track'] });
|
||||||
if (!runners || runners.length == 0) {
|
if (!runners || runners.length == 0) {
|
||||||
return [];
|
return [];
|
||||||
@@ -60,6 +80,10 @@ export class StatsController {
|
|||||||
topRunners.forEach(runner => {
|
topRunners.forEach(runner => {
|
||||||
responseRunners.push(new ResponseStatsRunner(runner));
|
responseRunners.push(new ResponseStatsRunner(runner));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Store in cache for 60 seconds
|
||||||
|
await setStatsCache('runners.distance', responseRunners);
|
||||||
|
|
||||||
return responseRunners;
|
return responseRunners;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +92,13 @@ export class StatsController {
|
|||||||
@ResponseSchema(ResponseStatsRunner, { isArray: true })
|
@ResponseSchema(ResponseStatsRunner, { isArray: true })
|
||||||
@OpenAPI({ description: "Returns the top ten runners by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
@OpenAPI({ description: "Returns the top ten runners by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
||||||
async getTopRunnersByDonations() {
|
async getTopRunnersByDonations() {
|
||||||
|
// Try cache first
|
||||||
|
const cached = await getStatsCache<ResponseStatsRunner[]>('runners.donations');
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss - compute fresh stats
|
||||||
let runners = await getConnection().getRepository(Runner).find({ relations: ['group', 'distanceDonations', 'distanceDonations.runner', 'distanceDonations.runner.scans', 'distanceDonations.runner.scans.track'] });
|
let runners = await getConnection().getRepository(Runner).find({ relations: ['group', 'distanceDonations', 'distanceDonations.runner', 'distanceDonations.runner.scans', 'distanceDonations.runner.scans.track'] });
|
||||||
if (!runners || runners.length == 0) {
|
if (!runners || runners.length == 0) {
|
||||||
return [];
|
return [];
|
||||||
@@ -77,6 +108,10 @@ export class StatsController {
|
|||||||
topRunners.forEach(runner => {
|
topRunners.forEach(runner => {
|
||||||
responseRunners.push(new ResponseStatsRunner(runner));
|
responseRunners.push(new ResponseStatsRunner(runner));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Store in cache for 60 seconds
|
||||||
|
await setStatsCache('runners.donations', responseRunners);
|
||||||
|
|
||||||
return responseRunners;
|
return responseRunners;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +120,14 @@ export class StatsController {
|
|||||||
@ResponseSchema(ResponseStatsRunner, { isArray: true })
|
@ResponseSchema(ResponseStatsRunner, { isArray: true })
|
||||||
@OpenAPI({ description: "Returns the top ten runners by fastest laptime on your selected track (track by id).", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
@OpenAPI({ description: "Returns the top ten runners by fastest laptime on your selected track (track by id).", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
||||||
async getTopRunnersByLaptime(@QueryParam("track") track: number) {
|
async getTopRunnersByLaptime(@QueryParam("track") track: number) {
|
||||||
|
// Try cache first (cache key includes track id, using dots for NATS KV compatibility)
|
||||||
|
const cacheKey = `runners.laptime.${track}`;
|
||||||
|
const cached = await getStatsCache<ResponseStatsRunner[]>(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss - compute fresh stats
|
||||||
let scans = await getConnection().getRepository(TrackScan).find({ relations: ['track', 'runner', 'runner.group', 'runner.scans', 'runner.scans.track', 'runner.distanceDonations'] });
|
let scans = await getConnection().getRepository(TrackScan).find({ relations: ['track', 'runner', 'runner.group', 'runner.scans', 'runner.scans.track', 'runner.distanceDonations'] });
|
||||||
if (!scans || scans.length == 0) {
|
if (!scans || scans.length == 0) {
|
||||||
return [];
|
return [];
|
||||||
@@ -105,6 +148,10 @@ export class StatsController {
|
|||||||
topScans.forEach(scan => {
|
topScans.forEach(scan => {
|
||||||
responseRunners.push(new ResponseStatsRunner(scan.runner, scan.lapTime));
|
responseRunners.push(new ResponseStatsRunner(scan.runner, scan.lapTime));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Store in cache for 60 seconds
|
||||||
|
await setStatsCache(cacheKey, responseRunners);
|
||||||
|
|
||||||
return responseRunners;
|
return responseRunners;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +168,13 @@ export class StatsController {
|
|||||||
@ResponseSchema(ResponseStatsTeam, { isArray: true })
|
@ResponseSchema(ResponseStatsTeam, { isArray: true })
|
||||||
@OpenAPI({ description: "Returns the top ten teams by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
@OpenAPI({ description: "Returns the top ten teams by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
||||||
async getTopTeamsByDistance() {
|
async getTopTeamsByDistance() {
|
||||||
|
// Try cache first
|
||||||
|
const cached = await getStatsCache<ResponseStatsTeam[]>('teams.distance');
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss - compute fresh stats
|
||||||
let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ['parentGroup', 'runners', 'runners.scans', 'runners.scans.track'] });
|
let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ['parentGroup', 'runners', 'runners.scans', 'runners.scans.track'] });
|
||||||
if (!teams || teams.length == 0) {
|
if (!teams || teams.length == 0) {
|
||||||
return [];
|
return [];
|
||||||
@@ -130,6 +184,10 @@ export class StatsController {
|
|||||||
topTeams.forEach(team => {
|
topTeams.forEach(team => {
|
||||||
responseTeams.push(new ResponseStatsTeam(team));
|
responseTeams.push(new ResponseStatsTeam(team));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Store in cache for 60 seconds
|
||||||
|
await setStatsCache('teams.distance', responseTeams);
|
||||||
|
|
||||||
return responseTeams;
|
return responseTeams;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +196,13 @@ export class StatsController {
|
|||||||
@ResponseSchema(ResponseStatsTeam, { isArray: true })
|
@ResponseSchema(ResponseStatsTeam, { isArray: true })
|
||||||
@OpenAPI({ description: "Returns the top ten teams by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
@OpenAPI({ description: "Returns the top ten teams by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
||||||
async getTopTeamsByDonations() {
|
async getTopTeamsByDonations() {
|
||||||
|
// Try cache first
|
||||||
|
const cached = await getStatsCache<ResponseStatsTeam[]>('teams.donations');
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss - compute fresh stats
|
||||||
let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ['parentGroup', 'runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track'] });
|
let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ['parentGroup', 'runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track'] });
|
||||||
if (!teams || teams.length == 0) {
|
if (!teams || teams.length == 0) {
|
||||||
return [];
|
return [];
|
||||||
@@ -147,6 +212,10 @@ export class StatsController {
|
|||||||
topTeams.forEach(team => {
|
topTeams.forEach(team => {
|
||||||
responseTeams.push(new ResponseStatsTeam(team));
|
responseTeams.push(new ResponseStatsTeam(team));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Store in cache for 60 seconds
|
||||||
|
await setStatsCache('teams.donations', responseTeams);
|
||||||
|
|
||||||
return responseTeams;
|
return responseTeams;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +224,13 @@ export class StatsController {
|
|||||||
@ResponseSchema(ResponseStatsOrgnisation, { isArray: true })
|
@ResponseSchema(ResponseStatsOrgnisation, { isArray: true })
|
||||||
@OpenAPI({ description: "Returns the top ten organizations by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
@OpenAPI({ description: "Returns the top ten organizations by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
||||||
async getTopOrgsByDistance() {
|
async getTopOrgsByDistance() {
|
||||||
|
// Try cache first
|
||||||
|
const cached = await getStatsCache<ResponseStatsOrgnisation[]>('organizations.distance');
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss - compute fresh stats
|
||||||
let orgs = await getConnection().getRepository(RunnerOrganization).find({ relations: ['runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track', 'teams', 'teams.runners', 'teams.runners.scans', 'teams.runners.distanceDonations', 'teams.runners.scans.track'] });
|
let orgs = await getConnection().getRepository(RunnerOrganization).find({ relations: ['runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track', 'teams', 'teams.runners', 'teams.runners.scans', 'teams.runners.distanceDonations', 'teams.runners.scans.track'] });
|
||||||
if (!orgs || orgs.length == 0) {
|
if (!orgs || orgs.length == 0) {
|
||||||
return [];
|
return [];
|
||||||
@@ -164,6 +240,10 @@ export class StatsController {
|
|||||||
topOrgs.forEach(org => {
|
topOrgs.forEach(org => {
|
||||||
responseOrgs.push(new ResponseStatsOrgnisation(org));
|
responseOrgs.push(new ResponseStatsOrgnisation(org));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Store in cache for 60 seconds
|
||||||
|
await setStatsCache('organizations.distance', responseOrgs);
|
||||||
|
|
||||||
return responseOrgs;
|
return responseOrgs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +252,13 @@ export class StatsController {
|
|||||||
@ResponseSchema(ResponseStatsOrgnisation, { isArray: true })
|
@ResponseSchema(ResponseStatsOrgnisation, { isArray: true })
|
||||||
@OpenAPI({ description: "Returns the top ten organizations by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
@OpenAPI({ description: "Returns the top ten organizations by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
||||||
async getTopOrgsByDonations() {
|
async getTopOrgsByDonations() {
|
||||||
|
// Try cache first
|
||||||
|
const cached = await getStatsCache<ResponseStatsOrgnisation[]>('organizations.donations');
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss - compute fresh stats
|
||||||
let orgs = await getConnection().getRepository(RunnerOrganization).find({ relations: ['runners', 'runners.distanceDonations', 'runners.distanceDonations.runner', 'runners.distanceDonations.runner.scans', 'runners.distanceDonations.runner.scans.track', 'teams', 'teams.runners', 'teams.runners.distanceDonations', 'teams.runners.distanceDonations.runner', 'teams.runners.distanceDonations.runner.scans', 'teams.runners.distanceDonations.runner.scans.track'] });
|
let orgs = await getConnection().getRepository(RunnerOrganization).find({ relations: ['runners', 'runners.distanceDonations', 'runners.distanceDonations.runner', 'runners.distanceDonations.runner.scans', 'runners.distanceDonations.runner.scans.track', 'teams', 'teams.runners', 'teams.runners.distanceDonations', 'teams.runners.distanceDonations.runner', 'teams.runners.distanceDonations.runner.scans', 'teams.runners.distanceDonations.runner.scans.track'] });
|
||||||
if (!orgs || orgs.length == 0) {
|
if (!orgs || orgs.length == 0) {
|
||||||
return [];
|
return [];
|
||||||
@@ -181,6 +268,10 @@ export class StatsController {
|
|||||||
topOrgs.forEach(org => {
|
topOrgs.forEach(org => {
|
||||||
responseOrgs.push(new ResponseStatsOrgnisation(org));
|
responseOrgs.push(new ResponseStatsOrgnisation(org));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Store in cache for 60 seconds
|
||||||
|
await setStatsCache('organizations.donations', responseOrgs);
|
||||||
|
|
||||||
return responseOrgs;
|
return responseOrgs;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,7 +109,7 @@ export class CreateUser {
|
|||||||
newUser.lastname = this.lastname
|
newUser.lastname = this.lastname
|
||||||
newUser.uuid = crypto.randomUUID()
|
newUser.uuid = crypto.randomUUID()
|
||||||
newUser.phone = this.phone
|
newUser.phone = this.phone
|
||||||
newUser.password = Bun.password.hash(this.password + newUser.uuid);
|
newUser.password = await Bun.password.hash(this.password + newUser.uuid);
|
||||||
newUser.groups = await this.getGroups();
|
newUser.groups = await this.getGroups();
|
||||||
newUser.enabled = this.enabled;
|
newUser.enabled = this.enabled;
|
||||||
|
|
||||||
|
|||||||
86
src/nats/StatsKV.ts
Normal file
86
src/nats/StatsKV.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { KvEntry } from 'nats';
|
||||||
|
import NatsClient from './NatsClient';
|
||||||
|
|
||||||
|
const BUCKET = 'stats_cache';
|
||||||
|
const TTL_SECONDS = 60; // 60 second TTL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stats cache stored in NATS KV with 60 second TTL.
|
||||||
|
* Used to cache expensive aggregation queries from the stats endpoints.
|
||||||
|
*/
|
||||||
|
|
||||||
|
async function getBucket() {
|
||||||
|
return NatsClient.getKV(BUCKET, { ttl: TTL_SECONDS * 1000 }); // TTL in milliseconds
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache key patterns (using dots instead of colons for NATS KV compatibility):
|
||||||
|
* - "stats.overview" - main stats endpoint (GET /stats)
|
||||||
|
* - "stats.runners.distance" - top runners by distance
|
||||||
|
* - "stats.runners.donations" - top runners by donations
|
||||||
|
* - "stats.runners.laptime.{trackId}" - top runners by laptime for specific track
|
||||||
|
* - "stats.teams.distance" - top teams by distance
|
||||||
|
* - "stats.teams.donations" - top teams by donations
|
||||||
|
* - "stats.organizations.distance" - top organizations by distance
|
||||||
|
* - "stats.organizations.donations" - top organizations by donations
|
||||||
|
*/
|
||||||
|
|
||||||
|
function cacheKey(path: string): string {
|
||||||
|
// Replace colons with dots for NATS KV compatibility
|
||||||
|
return `stats.${path.replace(/:/g, '.')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the cached value for the given stats cache key, or null on a miss.
|
||||||
|
*/
|
||||||
|
export async function getStatsCache<T>(path: string): Promise<T | null> {
|
||||||
|
const bucket = await getBucket();
|
||||||
|
let entry: KvEntry | null = null;
|
||||||
|
try {
|
||||||
|
entry = await bucket.get(cacheKey(path));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!entry || entry.operation === 'DEL' || entry.operation === 'PURGE') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return JSON.parse(entry.string()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a value in the stats cache with 60 second TTL.
|
||||||
|
* The TTL is applied at the bucket level, so all entries expire automatically.
|
||||||
|
*/
|
||||||
|
export async function setStatsCache<T>(path: string, value: T): Promise<void> {
|
||||||
|
const bucket = await getBucket();
|
||||||
|
await bucket.put(cacheKey(path), JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the cached entry for the given stats path.
|
||||||
|
* Useful for cache invalidation when data changes.
|
||||||
|
*/
|
||||||
|
export async function deleteStatsCache(path: string): Promise<void> {
|
||||||
|
const bucket = await getBucket();
|
||||||
|
try {
|
||||||
|
await bucket.delete(cacheKey(path));
|
||||||
|
} catch {
|
||||||
|
// Entry doesn't exist or already deleted - ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes all cached stats entries.
|
||||||
|
* Call this when runners, scans, or donations are modified to ensure fresh data.
|
||||||
|
*/
|
||||||
|
export async function invalidateAllStats(): Promise<void> {
|
||||||
|
const bucket = await getBucket();
|
||||||
|
try {
|
||||||
|
// Purge the entire bucket to clear all cached stats
|
||||||
|
await bucket.destroy();
|
||||||
|
// Recreate the bucket for future use
|
||||||
|
await NatsClient.getKV(BUCKET, { ttl: TTL_SECONDS * 1000 });
|
||||||
|
} catch {
|
||||||
|
// Bucket operations can fail if bucket doesn't exist - ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user