diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..45e3ac5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,282 @@ +# AGENTS.md — LfK Backend + +Guidance for agentic coding agents working in this repository. + +--- + +## Project Overview + +Express + [`routing-controllers`](https://github.com/typestack/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 + +```sh +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 + +```sh +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 + +```sh +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. + +```sh +# 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:`. 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 enforced +- **`experimentalDecorators: true`** and **`emitDecoratorMetadata: true`** — required by `routing-controllers`, `TypeORM`, and `class-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-metadata` is imported once at the top of `src/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-validator` decorators (`@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 `any` where possible; when unavoidable, keep it localised +- `strict` is off — but still annotate types explicitly rather than relying on inference + +### Controller Pattern + +```typescript +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/` extending `routing-controllers` error types (`NotFoundError`, `NotAcceptableError`, etc.) +- Every custom error class must include `@IsString()` decorated `name` and `message` fields 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 `ErrorHandler` middleware (registered in `src/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/` and `src/models/actions/update/` +- Use `class-validator` decorators 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 the `Response{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 + +```typescript +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: undefined` in `axios_config` to prevent axios throwing on error responses +- Group tests by HTTP verb + route in `describe()` blocks; separate "working" and "failing" cases +- Use `jest.setTimeout(20000)` in `beforeAll` for slow integration tests +- Assert both `res.status` and `res.headers['content-type']` on success paths + +--- + +## Environment Configuration + +- Copy `.env.example` to `.env` and fill in values before running locally +- Database type is set via `DB_TYPE` env var (`sqlite`, `postgres`, or `mysql`) +- Server port is set via `INTERNAL_PORT` (accessed as `config.internal_port` in 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` — if `true`, 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.