-- NORMAL -- ~/projects/trekker.md project 05 / 06

trekker

A hiking API where every request first goes through the gauge of the shared contract.

Stack
TypeScript · NestJS · Drizzle
Year
2026
Repository
github.com/Popoch39/trekker ↗
A

Anatomy

8 parts · only one counts
Fig. 1 Card cabinet and Zod gauge
01234567890012345678900123456789001234567890/v1/treks
  1. 01GET /api/v1/treksL8–24 ↗

    Take the request

    Takes in the list request; with no open session, the API answers 401.

    TreksController

    15@ApiTags('treks')

    16@Controller('treks')

    17export class TreksController {

  2. 02pageSize ≤ 100L126–185 ↗

    Check every parameter

    One contract for the API, web and mobile: filters, position, 100 per page.

    trekListQuerySchema

    133export const trekListQuerySchema = paginationQuerySchema

    134 .extend({

    135 difficulty: trekDifficultySchema.optional(),

  3. Turn down a bad request

    Answers 422 with the list of faulty fields, in the standard RFC 9457 format.

    ZodValidationException

    52if (exception instanceof ZodValidationException) {

    53 return {

    54 ...base,

  4. 04limit / offsetL127–151 ↗

    Cut out a page

    Sorts by name then id, skips the earlier pages and keeps 20 routes.

    toLimitOffset()

    127const where = conditions.length > 0 ? and(...conditions) : undefined;

    128const { limit, offset } = toLimitOffset(query);

    130const [rows, total] = await Promise.all([

  5. 05Promise.allL152–160 ↗

    Count the results

    Counts every result at the same time: 100 routes make 5 pages.

    $count()

    154 this.db.$count(treks, where),

    155]);

    157return {

  6. 06toSummary()L49–74 ↗

    Slim down each result

    Serves name, distance and climb without the GPS track, too heavy for a list.

    trekSummarySchema

    55export const trekSummarySchema = entityBaseSchema.extend({

    56 name: nonEmptyString,

    57 distanceMeters: z.int().min(0),

  7. 07index(name, id)L142–148 ↗

    Speed up the sort

    An index on name then id: 0.9 ms instead of 130 ms per page, across 2,442 routes.

    treks_name_id_idx

    148index('treks_name_id_idx').on(table.name, table.id),

  8. 08pgTable('treks')L57–91 ↗

    Store the routes

    A Postgres table: one row per route, with its track and its starting point.

    treks

    57export const treks = pgTable(

    58 'treks',

    59 {

GET ?page=1 → 200 · 20 cards · p. 1/5 · total 100
Note view

Pull a handle to request its pageDrag a drawer towards you to request its page

Fig. 1 — Each drawer is a page of 20 routes out of the 100 in the bundled fixture. The request card first goes through the gauge, trekListQuerySchema, the Zod contract that the API, the web app and the mobile app share: an incomplete proximity search or a page of more than 100 cards ends up in the reject tray, as a 422. The cards sit on the rod in name, id order, which the treks_name_id_idx index follows (0.9 ms instead of 130 across 2,442 treks), and come out without their track.

Parts list

  1. 01 Lock slot Takes in the request card; the lock demands a session. Code: apps/api/src/modules/treks/treks.controller.ts, lines 8–24 ↗
  2. 02 Pin gauge Checks every field of the request card, and its interlocks, before letting it through. Code: packages/contracts/src/treks.ts, lines 126–185 ↗
  3. 03 Reject tray Catches the refused card, with the details of the faulty fields. Code: apps/api/src/common/filters/problem-details.filter.ts, lines 52–61 ↗
  4. 04 Rod and follower Holds the cards in name, id order; the follower sets twenty apart, after those of the earlier drawers. Code: apps/api/src/modules/treks/treks.service.ts, lines 127–151 ↗
  5. 05 Totaliser Counts every matching card while the drawer slides out, and works out the number of pages. Code: apps/api/src/modules/treks/treks.service.ts, lines 152–160 ↗
  6. 06 Summary card What comes out of the drawer: the card without its track, which stays at the bottom. Code: packages/contracts/src/treks.ts, lines 49–74 ↗
  7. 07 Ball-bearing runners Bring the drawer out without sorting the whole cabinet. Code: packages/db/src/schema/treks.ts, lines 142–148 ↗
  8. 08 Cabinet body The cabinet itself: each card is filed in it once. Code: packages/db/src/schema/treks.ts, lines 57–91 ↗