Version: 0.1.0-draft
Date: 2026-03-23
RoutingResilienceLayer ← circuit breaker, retry, timeout
└── RoutingManagerLayer ← caching, request normalisation, config
└── RoutingServiceLayer ← raw Valhalla HTTP calls
Responsibility: Raw HTTP communication with Valhalla. No business logic, no caching, no resilience.
Request DTO (Zod schemas — defined in packages/shared):
// packages/shared/src/dto/routing.dto.ts
import { z } from 'zod';
export const WaypointSchema = z.object({
lon: z.number().min(-180).max(180),
lat: z.number().min(-90).max(90),
label: z.string().max(100).optional(),
});
export const RoutingProfile = z.enum(['motorcycle', 'auto']);
export const RoutingOptionsSchema = z.object({
useHighways: z.number().min(0).max(1).default(0.5),
avoidBadSurfaces: z.number().min(0).max(1).default(0.5),
useFerry: z.boolean().default(true),
});
// POST /routing/calculate
export const CalculateRouteSchema = z.object({
waypoints: z.array(WaypointSchema).min(2).max(25),
profile: RoutingProfile.default('motorcycle'),
options: RoutingOptionsSchema.optional(),
});
export type CalculateRouteDto = z.infer<typeof CalculateRouteSchema>;
export type WaypointDto = z.infer<typeof WaypointSchema>;
export type RoutingOptionsDto = z.infer<typeof RoutingOptionsSchema>;Response DTO (Zod schemas):
// Response types (inferred from Zod or plain interfaces — not validated at boundary)
export interface CalculatedRouteDto {
geometry: GeoJsonLineString; // decoded from Valhalla polyline6
distanceM: number; // metres
durationS: number; // seconds
summary: string; // human-readable route summary
legs: RouteLegDto[];
}
export interface RouteLegDto {
distanceM: number;
durationS: number;
maneuvers: ManeuverDto[];
}
export interface ManeuverDto {
type: number; // Valhalla maneuver type
instruction: string;
lon: number;
lat: number;
}
// POST /routing/elevation
export const ElevationRequestSchema = z.object({
points: z.array(z.tuple([z.number(), z.number()])).min(2), // [lon, lat] pairs
});
export type ElevationRequestDto = z.infer<typeof ElevationRequestSchema>;
export interface ElevationResponseDto {
points: ElevationPointDto[];
ascentM: number;
descentM: number;
}
export interface ElevationPointDto {
lon: number;
lat: number;
elevationM: number;
distanceFromStartM: number;
}Valhalla raw request format:
// Route request to Valhalla
interface ValhallaRouteRequest {
locations: Array<{ lon: number; lat: number; type: 'break' }>;
costing: 'motorcycle' | 'auto';
costing_options: {
motorcycle: {
use_highways: number;
use_trails: number;
use_ferry: number;
avoid_bad_surfaces: number;
};
};
directions_options: {
units: 'kilometers';
language: 'en-US';
};
}
// Height request to Valhalla
interface ValhallaHeightRequest {
encoded_polyline: string; // polyline6 encoded
range: true; // include cumulative distance
}// apps/api/src/database/schema/routes.schema.ts
import { pgTable, uuid, varchar, text, integer, jsonb, timestamp, index } from 'drizzle-orm/pg-core';
import { pgEnum } from 'drizzle-orm/pg-core';
import { lineString, polygon } from 'drizzle-postgis';
import { users } from './users.schema';
export const routeDiscipline = pgEnum('route_discipline', ['touring', 'gravel', 'adventure']);
export const routeStatus = pgEnum('route_status', ['draft', 'public', 'unlisted']);
export const routes = pgTable('routes', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
title: varchar('title', { length: 200 }).notNull(),
description: text('description'),
discipline: routeDiscipline('discipline').notNull(),
status: routeStatus('status').notNull().default('draft'),
// PostGIS geometry (native via drizzle-postgis)
geometry: lineString('geometry', { srid: 4326 }).notNull(),
boundingBox: polygon('bounding_box', { srid: 4326 }),
// Computed stats
distanceM: integer('distance_m').notNull(),
durationS: integer('duration_s'),
ascentM: integer('ascent_m'),
descentM: integer('descent_m'),
countryCodes: text('country_codes').array().notNull().default([]), // from Valhalla admin info
// Waypoints
waypoints: jsonb('waypoints').notNull().default([]),
// Metadata
viewCount: integer('view_count').notNull().default(0), // buffered in Redis
saveCount: integer('save_count').notNull().default(0),
coverPhotoUrl: text('cover_photo_url'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
deletedAt: timestamp('deleted_at', { withTimezone: true }), // soft delete
}, (table) => [
index('routes_user_id_idx').on(table.userId),
index('routes_discipline_idx').on(table.discipline),
index('routes_status_idx').on(table.status),
// GIST indexes on geometry columns created via raw SQL migration
// GIN index on country_codes created via raw SQL migration
// Partial index on deleted_at created via raw SQL migration
]);
// Elevation data in separate table (lazy-loaded, keeps list queries fast)
export const routeElevation = pgTable('route_elevation', {
id: uuid('id').primaryKey().defaultRandom(),
routeId: uuid('route_id').notNull().unique().references(() => routes.id, { onDelete: 'cascade' }),
elevationData: jsonb('elevation_data').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
index('route_elevation_route_id_idx').on(table.routeId),
]);
// Type exports
export type Route = typeof routes.$inferSelect;
export type NewRoute = typeof routes.$inferInsert;
export type RouteElevation = typeof routeElevation.$inferSelect;Notes:
- PostGIS geometry columns use
drizzle-postgistypes (lineString,polygon) — no manual WKT/GeoJSON serialization needed. - GIST/GIN indexes require raw SQL in migrations (Drizzle doesn't support them declaratively yet).
- All queries must filter by
deletedAt IS NULLat the repository level. - List/browse queries must explicitly select columns — never
SELECT *. Excludegeometrycolumn and never joinroute_elevationin list queries.
---
## 3. GPX Export Format
```xml
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1"
creator="MotoTrail"
xmlns="http://www.topografix.com/GPX/1/1"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">
<metadata>
<name>{route.title}</name>
<desc>{route.description}</desc>
<author><name>MotoTrail</name></author>
<link href="https://mototrail.app/routes/{route.id}">
<text>View on MotoTrail</text>
</link>
<time>{route.createdAt.toISOString()}</time>
<keywords>motorcycle, {route.discipline}</keywords>
</metadata>
<trk>
<name>{route.title}</name>
<type>{route.discipline}</type>
<trkseg>
<!-- One trkpt per coordinate in the LineString geometry -->
<trkpt lat="{lat}" lon="{lon}">
<ele>{elevationM}</ele>
</trkpt>
...
</trkseg>
</trk>
<!-- Waypoints as wpt elements -->
{route.waypoints.map(wp =>
`<wpt lat="${wp.lat}" lon="${wp.lon}">
<name>${wp.label ?? 'Waypoint'}</name>
</wpt>`
)}
</gpx>
All API calls go through a typed client. No raw fetch or axios calls in components.
// lib/api/client.ts
import axios from 'axios';
export const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
timeout: 15000,
headers: { 'Content-Type': 'application/json' },
withCredentials: true, // send HttpOnly refresh token cookie with every request
});
// Attach in-memory access token to requests
apiClient.interceptors.request.use((config) => {
const token = useAuthStore.getState().accessToken;
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// Handle 401 → silent refresh via HttpOnly cookie
// The refresh token is in an HttpOnly cookie (set by the API), so JS never touches it.
// POST /auth/refresh reads the cookie server-side and returns a new access token in the body.
apiClient.interceptors.response.use(
(res) => res,
async (error) => {
if (error.response?.status === 401 && !error.config._retry) {
error.config._retry = true;
try {
const { data } = await axios.post(
`${process.env.NEXT_PUBLIC_API_URL}/auth/refresh`,
{},
{ withCredentials: true }, // send the HttpOnly cookie
);
useAuthStore.getState().setAccessToken(data.data.accessToken);
error.config.headers.Authorization = `Bearer ${data.data.accessToken}`;
return apiClient(error.config);
} catch {
useAuthStore.getState().logout();
return Promise.reject(error);
}
}
return Promise.reject(error);
}
);Auth token storage strategy:
- Access token: stored in Zustand store (JavaScript memory only). Lost on page refresh — silently re-acquired via refresh endpoint.
- Refresh token: HttpOnly, Secure, SameSite=Strict cookie set by the API. Inaccessible to JavaScript — immune to XSS token theft.
- No
localStorageorsessionStorageis used for tokens.
// lib/api/routes.api.ts
export const routesApi = {
browse: (params: RouteQueryParams) =>
apiClient.get<PaginatedResponse<RouteListItem>>('/routes', { params }),
getById: (id: string) =>
apiClient.get<Route>(`/routes/${id}`),
create: (dto: CreateRouteDto) =>
apiClient.post<Route>('/routes', dto),
update: (id: string, dto: UpdateRouteDto) =>
apiClient.patch<Route>(`/routes/${id}`, dto),
delete: (id: string) =>
apiClient.delete(`/routes/${id}`),
getGpxUrl: (id: string) =>
`${process.env.NEXT_PUBLIC_API_URL}/routes/${id}/gpx`,
uploadPhoto: (id: string, file: File) => {
const form = new FormData();
form.append('file', file);
return apiClient.post<RoutePhoto>(`/routes/${id}/photos`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
},
};
// lib/api/routing.api.ts
export const routingApi = {
calculate: (dto: CalculateRouteDto) =>
apiClient.post<CalculatedRouteDto>('/routing/calculate', dto),
elevation: (dto: ElevationRequestDto) =>
apiClient.post<ElevationResponseDto>('/routing/elevation', dto),
};The route planner follows this state machine:
IDLE
→ (user clicks map or drops waypoint)
WAYPOINTS_SET (< 2 waypoints)
→ (second waypoint added)
CALCULATING
→ (Valhalla responds successfully)
ROUTE_READY
→ (user modifies waypoints)
CALCULATING (recalculate)
→ (Valhalla error)
CALCULATION_ERROR
→ (user clears all waypoints)
IDLE
Corresponding Zustand store actions:
addWaypoint(lon: number, lat: number): void
removeWaypoint(index: number): void
moveWaypoint(index: number, lon: number, lat: number): void
reorderWaypoints(from: number, to: number): void
clearRoute(): void
calculateRoute(): Promise<void> // calls routingApi.calculate, handles state transitions
saveRoute(meta: RouteMetaDto): Promise<Route> // requires auth// infra/valhalla/valhalla.json (Docker config)
{
"mjolnir": {
"tile_dir": "/custom_files/valhalla_tiles",
"timezone": "/custom_files/timezones.sqlite",
"admin": "/custom_files/admins.sqlite"
},
"thor": {
"logging": { "type": "std_out", "color": false, "level": "WARN" }
},
"httpd": {
"service": {
"listen": "tcp://0.0.0.0:8002",
"loopback": "ipc:///tmp/loopback",
"interrupt": "ipc:///tmp/interrupt"
}
},
"service_limits": {
"auto": { "max_distance": 5000000.0, "max_locations": 20 },
"motorcycle": { "max_distance": 5000000.0, "max_locations": 25 },
"max_matrix_distance": 400000.0,
"max_matrix_location_pairs": 25
}
}// NestJS resilience layer config
export const VALHALLA_RESILIENCE_CONFIG = {
circuitBreaker: {
timeout: 10_000, // ms — individual request timeout
errorThresholdPercentage: 50,
resetTimeout: 30_000, // ms — how long before trying again
volumeThreshold: 5, // min requests before circuit can open
},
retry: {
maxAttempts: 3,
delays: [100, 200, 400], // ms — exponential backoff
retryOn: [500, 502, 503, 504], // HTTP status codes to retry
},
} as const;