12 KiB
AGENTS.md — LfK Backend
Guidance for agentic coding agents working in this repository.
Project Overview
Express + routing-controllers REST API written in TypeScript. Uses TypeORM for database access (SQLite in dev/test, PostgreSQL or MySQL in production). OpenAPI docs are auto-generated from decorators at startup.
Runtime & Package Manager: Bun (replaces Node.js + npm/pnpm).
Build / Run / Test Commands
Development
bun run dev # Start dev server with auto-reload (uses Bun's --watch)
Auto-reload: The dev script uses Bun's built-in --watch flag, which automatically restarts the server when TypeScript files in src/ change. Bun runs TypeScript directly - no build step needed.
Performance: Bun delivers 8-15% better latency under concurrent load compared to Node.js. See BUN_BENCHMARK_RESULTS.md for details.
Build
bun run build # rimraf dist && tsc && copy static assets → dist/
Note: The build script exists for legacy compatibility and type-checking, but is not required for development or production. Bun runs TypeScript source files directly.
Production
bun start # bun src/app.ts (runs TypeScript directly)
Tests
Tests are integration tests that hit a live running server via HTTP. The server must be started before Jest is invoked.
# Full CI test flow (generates .env, starts server, runs jest):
bun run test:ci
# Run Jest directly (server must already be running):
bun test
# Watch mode:
bun run test:watch
# Run a single test file:
bunx jest src/tests/runners/runner_add.spec.ts
# Run tests matching a name pattern:
bunx jest --testNamePattern="POST /api/runners"
# Run all tests in a subdirectory:
bunx jest src/tests/runners/
Run all tests in a subdirectory:
bunx jest src/tests/runners/
> **Important:** `bun test` alone will fail unless the dev server is already running on `http://localhost:<config.internal_port>`. In CI, `start-server-and-test` handles this automatically via `bun run test:ci`.
### Other Utilities
```sh
bun run seed # Sync DB schema and run seeders
bun run openapi:export # Export OpenAPI spec to file
bun run docs # Generate TypeDoc documentation
bun run licenses:export # Export third-party license report
TypeScript Configuration
- Target: ES2020, Module: CommonJS
strict: false— TypeScript strictness is disabled; types are used but not exhaustively enforcedexperimentalDecorators: trueandemitDecoratorMetadata: true— required byrouting-controllers,TypeORM, andclass-validator- Spec files (
**/*.spec.ts) are excluded from compilation - Source root:
src/, output:dist/
Code Style Guidelines
No Linter / Formatter Configured
There is no ESLint or Prettier configuration. Follow the patterns already established in the codebase rather than introducing new tooling.
Imports
- Use named imports for decorator packages:
import { Get, JsonController, Param } from 'routing-controllers' - Use named imports for TypeORM:
import { Column, Entity, getConnectionManager } from 'typeorm' - Use named imports for class-validator:
import { IsInt, IsOptional, IsString } from 'class-validator' - Use
import * as X from 'module'for modules without clean default exports (e.g.,import * as jwt from 'jsonwebtoken') - Use default imports for simple modules (e.g.,
import cookie from 'cookie') reflect-metadatais imported once at the top ofsrc/app.ts— do not re-import it- No barrel/index re-export files; import source files directly by path
Naming Conventions
| Construct | Convention | Example |
|---|---|---|
| Classes | PascalCase |
RunnerController, CreateRunner |
| Files | PascalCase.ts matching class name |
RunnerController.ts |
| Local variables | camelCase (some snake_case in tests) |
accessToken, access_token |
| DB entity fields | snake_case preferred |
created_at, updated_at |
| Controller methods | REST-conventional | getAll, getOne, post, put, remove |
| Custom errors | {Entity}{Issue}Error |
RunnerNotFoundError, RunnerIdsNotMatchingError |
| Response DTOs | Response{Entity} |
ResponseRunner, ResponseAuth |
| Create DTOs | Create{Entity} |
CreateRunner |
| Update DTOs | Update{Entity} |
UpdateRunner |
| Enums | PascalCase |
ResponseObjectType, PermissionAction |
Formatting
- 4-space indentation (observed throughout the codebase)
- Single quotes for string literals in most files
- No trailing semicolons style inconsistency — follow what's already in the file you're editing
Types
- Add TypeScript types to all function parameters and return values
- Use
class-validatordecorators (@IsString,@IsInt,@IsOptional,@IsUUID, etc.) on every DTO and response class field — these drive both runtime validation and OpenAPI schema generation - Use abstract classes for shared entity base types (e.g.,
abstract class Participant) - Use interfaces for response contracts (e.g.,
interface IResponse) - Use enums for typed string/number constants
- Avoid
anywhere possible; when unavoidable, keep it localised strictis off — but still annotate types explicitly rather than relying on inference
Controller Pattern
import { Authorized, Body, Delete, Get, JsonController, Param, Post, Put } from 'routing-controllers';
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
@JsonController('/runners')
@Authorized()
export class RunnerController {
@Get('/')
@OpenAPI({ description: 'Returns all runners' })
@ResponseSchema(ResponseRunner, { isArray: true })
async getAll() { ... }
@Get('/:id')
@ResponseSchema(ResponseRunner)
async getOne(@Param('id') id: number) { ... }
@Post('/')
@ResponseSchema(ResponseRunner)
async post(@Body({ validate: true }) createRunner: CreateRunner) { ... }
@Put('/:id')
@ResponseSchema(ResponseRunner)
async put(@Param('id') id: number, @Body({ validate: true }) updateRunner: UpdateRunner) { ... }
@Delete('/:id')
@ResponseSchema(ResponseRunner)
async remove(@Param('id') id: number) { ... }
}
Error Handling
- Define custom error classes in
src/errors/extendingrouting-controllerserror types (NotFoundError,NotAcceptableError, etc.) - Every custom error class must include
@IsString()decoratednameandmessagefields for OpenAPI schema generation - Throw custom errors directly in controllers:
throw new RunnerNotFoundError() - Use try/catch in controllers and re-throw meaningful errors; do not swallow errors silently
- The global
ErrorHandlermiddleware (registered insrc/middlewares/) catches all unhandled errors and serialises them as JSON — do not duplicate this logic in controllers - Auth errors are thrown from
src/middlewares/authchecker.ts, not from individual controllers
Entity Pattern (TypeORM)
- Entities live in
src/models/entities/ - Decorate every entity with
@Entity()and every column with the appropriate@Column,@PrimaryGeneratedColumn, etc. - Use
@CreateDateColumn()/@UpdateDateColumn()for timestamp fields - Use table inheritance (
@TableInheritance+@ChildEntity) for polymorphic entities - Access repositories via
getConnectionManager().get().getRepository(EntityClass)— do not inject repositories as constructor dependencies - Database schema is synchronised automatically on startup (
connection.synchronize()) — no manual migration files
DTO Pattern (Create / Update)
- Create DTOs in
src/models/actions/create/andsrc/models/actions/update/ - Use
class-validatordecorators for every field @IsOptional()for fields that are not required on update; all fields on create DTOs should be mandatory unless explicitly optional in the API contract- Response DTOs live in
src/models/responses/and follow theResponse{Entity}naming pattern
Test Style Guidelines
IMPORTANT: Do not run existing tests and do not create new tests. The existing test suite in
src/tests/is outdated and no longer reflects the current state of the codebase. Ignore all test files when working in this repository. Do not write new tests for any changes or additions.
All tests are integration tests in src/tests/ organised by domain entity:
src/tests/
auth/
auth_login.spec.ts
auth_refresh.spec.ts
runners/
runner_add.spec.ts
runner_get.spec.ts
runner_update.spec.ts
runner_delete.spec.ts
...
Test File Template
import axios from 'axios';
import { config } from '../../config';
const base = "http://localhost:" + config.internal_port;
let access_token: string;
let axios_config: object;
beforeAll(async () => {
jest.setTimeout(20000);
const res = await axios.post(base + '/api/auth/login', { username: "demo", password: "demo" });
access_token = res.data["access_token"];
axios_config = {
headers: { "authorization": "Bearer " + access_token },
validateStatus: undefined // prevents axios from throwing on non-2xx responses
};
});
describe('POST /api/runners working', () => {
it('creating a runner with required params should return 200', async () => {
const res = await axios.post(base + '/api/runners', { ... }, axios_config);
expect(res.status).toEqual(200);
expect(res.headers['content-type']).toContain("application/json");
});
});
describe('POST /api/runners failing', () => {
it('creating a runner without required params should return 400', async () => {
const res = await axios.post(base + '/api/runners', {}, axios_config);
expect(res.status).toEqual(400);
});
});
- Always set
validateStatus: undefinedinaxios_configto prevent axios throwing on error responses - Group tests by HTTP verb + route in
describe()blocks; separate "working" and "failing" cases - Use
jest.setTimeout(20000)inbeforeAllfor slow integration tests - Assert both
res.statusandres.headers['content-type']on success paths
Environment Configuration
- Copy
.env.exampleto.envand fill in values before running locally - Database type is set via
DB_TYPEenv var (sqlite,postgres, ormysql) - Server port is set via
INTERNAL_PORT(accessed asconfig.internal_portin code) - All config values are validated at startup in
src/config.ts - CI env is generated by
bun run test:ci:generate_env(scripts/create_testenv.ts)
NATS Configuration
The backend uses NATS JetStream as a KV cache for scan intake performance optimization.
NATS_URL— connection URL for NATS server (default:nats://localhost:4222)NATS_PREWARM— iftrue, preloads all runner state into the KV cache at startup to eliminate DB reads from the first scan onward (default:false)
KV buckets (auto-created by NatsClient at startup):
station_state— station token cache (1-hour TTL)card_state— card→runner mapping cache (1-hour TTL)runner_state— runner display name, total distance, latest scan timestamp (no TTL, CAS-based updates)
Development: NATS runs in Docker via docker-compose.yml (port 4222). The JetStream volume is persisted to ./nats-data/ to survive container restarts.
Station intake hot path: POST /api/scans/trackscans from scan stations uses a KV-first flow that eliminates DB reads on cache hits and prevents race conditions via compare-and-swap (CAS) updates. See SCAN_NATS_PLAN.md for full architecture details.