Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
1d9c451dfb
|
|||
|
3197498ab3
|
|||
|
a1a2c2747c
|
|||
|
80e7e7939c
|
|||
|
6caa1850e3
|
10
CHANGELOG.md
10
CHANGELOG.md
@@ -2,9 +2,19 @@
|
|||||||
|
|
||||||
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)
|
#### [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)
|
- 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
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.1",
|
"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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user