Migrate geo backend from Django/Graphene to Express + Apollo Server + arangojs
All checks were successful
Build Docker Image / build (push) Successful in 1m5s

Replace Python stack with TypeScript. All 30+ GraphQL queries preserved including
phase-based routing (Dijkstra), H3 clustering, K_SHORTEST_PATHS, and external
routing services (GraphHopper, OpenRailRouting). Single public endpoint, no auth.
This commit is contained in:
Ruslan Bakiev
2026-03-09 09:45:22 +07:00
parent 31fc8cbc34
commit 52cbed91f8
30 changed files with 5162 additions and 3529 deletions

192
src/cluster.ts Normal file
View File

@@ -0,0 +1,192 @@
import { latLngToCell, cellToLatLng } from 'h3-js'
import { getDb } from './db.js'
const ZOOM_TO_RES: Record<number, number> = {
0: 0, 1: 0, 2: 1, 3: 1, 4: 2, 5: 2,
6: 3, 7: 3, 8: 4, 9: 4, 10: 5, 11: 5,
12: 6, 13: 7, 14: 8, 15: 9, 16: 10,
}
interface CachedNode {
_key: string
name?: string
latitude?: number
longitude?: number
country?: string
country_code?: string
node_type?: string
transport_types?: string[]
}
const nodesCache = new Map<string, CachedNode[]>()
function fetchNodes(transportType?: string | null, nodeType?: string | null): CachedNode[] {
const cacheKey = `nodes:${transportType || 'all'}:${nodeType || 'logistics'}`
if (nodesCache.has(cacheKey)) return nodesCache.get(cacheKey)!
const db = getDb()
let aql: string
if (nodeType === 'offer') {
aql = `
FOR node IN nodes
FILTER node.node_type == 'offer'
FILTER node.latitude != null AND node.longitude != null
RETURN node
`
} else if (nodeType === 'supplier') {
aql = `
FOR offer IN nodes
FILTER offer.node_type == 'offer'
FILTER offer.supplier_uuid != null
LET supplier = DOCUMENT(CONCAT('nodes/', offer.supplier_uuid))
FILTER supplier != null
FILTER supplier.latitude != null AND supplier.longitude != null
COLLECT sup_uuid = offer.supplier_uuid INTO offers
LET sup = DOCUMENT(CONCAT('nodes/', sup_uuid))
RETURN {
_key: sup_uuid,
name: sup.name,
latitude: sup.latitude,
longitude: sup.longitude,
country: sup.country,
country_code: sup.country_code,
node_type: 'supplier',
offers_count: LENGTH(offers)
}
`
} else {
aql = `
FOR node IN nodes
FILTER node.node_type == 'logistics' OR node.node_type == null
FILTER node.latitude != null AND node.longitude != null
RETURN node
`
}
// arangojs query returns a cursor — we need async. Use a sync cache pattern with pre-fetching.
// Since this is called from resolvers which are async, we'll use a different approach.
// Store a promise instead.
throw new Error('Use fetchNodesAsync instead')
}
export async function fetchNodesAsync(transportType?: string | null, nodeType?: string | null): Promise<CachedNode[]> {
const cacheKey = `nodes:${transportType || 'all'}:${nodeType || 'logistics'}`
if (nodesCache.has(cacheKey)) return nodesCache.get(cacheKey)!
const db = getDb()
let aql: string
if (nodeType === 'offer') {
aql = `
FOR node IN nodes
FILTER node.node_type == 'offer'
FILTER node.latitude != null AND node.longitude != null
RETURN node
`
} else if (nodeType === 'supplier') {
aql = `
FOR offer IN nodes
FILTER offer.node_type == 'offer'
FILTER offer.supplier_uuid != null
LET supplier = DOCUMENT(CONCAT('nodes/', offer.supplier_uuid))
FILTER supplier != null
FILTER supplier.latitude != null AND supplier.longitude != null
COLLECT sup_uuid = offer.supplier_uuid INTO offers
LET sup = DOCUMENT(CONCAT('nodes/', sup_uuid))
RETURN {
_key: sup_uuid,
name: sup.name,
latitude: sup.latitude,
longitude: sup.longitude,
country: sup.country,
country_code: sup.country_code,
node_type: 'supplier',
offers_count: LENGTH(offers)
}
`
} else {
aql = `
FOR node IN nodes
FILTER node.node_type == 'logistics' OR node.node_type == null
FILTER node.latitude != null AND node.longitude != null
RETURN node
`
}
const cursor = await db.query(aql)
let allNodes: CachedNode[] = await cursor.all()
if (transportType && (!nodeType || nodeType === 'logistics')) {
allNodes = allNodes.filter(n => (n.transport_types || []).includes(transportType))
}
nodesCache.set(cacheKey, allNodes)
console.log(`Cached ${allNodes.length} nodes for ${cacheKey}`)
return allNodes
}
export interface ClusterPoint {
id: string
latitude: number
longitude: number
count: number
expansion_zoom: number | null
name: string | null
}
export async function getClusteredNodes(
west: number, south: number, east: number, north: number,
zoom: number, transportType?: string | null, nodeType?: string | null,
): Promise<ClusterPoint[]> {
const resolution = ZOOM_TO_RES[Math.floor(zoom)] ?? 5
const nodes = await fetchNodesAsync(transportType, nodeType)
if (!nodes.length) return []
const cells = new Map<string, CachedNode[]>()
for (const node of nodes) {
const lat = node.latitude
const lng = node.longitude
if (lat == null || lng == null) continue
if (lat < south || lat > north || lng < west || lng > east) continue
const cell = latLngToCell(lat, lng, resolution)
if (!cells.has(cell)) cells.set(cell, [])
cells.get(cell)!.push(node)
}
const results: ClusterPoint[] = []
for (const [cell, nodesInCell] of cells) {
if (nodesInCell.length === 1) {
const node = nodesInCell[0]
results.push({
id: node._key,
latitude: node.latitude!,
longitude: node.longitude!,
count: 1,
expansion_zoom: null,
name: node.name || null,
})
} else {
const [lat, lng] = cellToLatLng(cell)
results.push({
id: `cluster-${cell}`,
latitude: lat,
longitude: lng,
count: nodesInCell.length,
expansion_zoom: Math.min(zoom + 2, 16),
name: null,
})
}
}
return results
}
export function invalidateCache(): void {
nodesCache.clear()
console.log('Cluster cache invalidated')
}

27
src/db.ts Normal file
View File

@@ -0,0 +1,27 @@
import { Database } from 'arangojs'
const ARANGODB_URL = process.env.ARANGODB_INTERNAL_URL || 'http://localhost:8529'
const ARANGODB_DATABASE = process.env.ARANGODB_DATABASE || 'optovia_maps'
const ARANGODB_PASSWORD = process.env.ARANGODB_PASSWORD || ''
let _db: Database | null = null
export function getDb(): Database {
if (!_db) {
const url = ARANGODB_URL.startsWith('http') ? ARANGODB_URL : `http://${ARANGODB_URL}`
_db = new Database({ url, databaseName: ARANGODB_DATABASE, auth: { username: 'root', password: ARANGODB_PASSWORD } })
console.log(`Connected to ArangoDB: ${url}/${ARANGODB_DATABASE}`)
}
return _db
}
export async function ensureGraph(): Promise<void> {
const db = getDb()
const graphs = await db.listGraphs()
if (graphs.some(g => g.name === 'optovia_graph')) return
console.log('Creating graph: optovia_graph')
await db.createGraph('optovia_graph', [
{ collection: 'edges', from: ['nodes'], to: ['nodes'] },
])
}

90
src/helpers.ts Normal file
View File

@@ -0,0 +1,90 @@
/** Haversine distance in km. */
export function distanceKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371
const dLat = (lat2 - lat1) * Math.PI / 180
const dLon = (lon2 - lon1) * Math.PI / 180
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon / 2) ** 2
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ArangoDoc = Record<string, any>
export interface RouteStage {
from_uuid: string | null
from_name: string | null
from_lat: number | null
from_lon: number | null
to_uuid: string | null
to_name: string | null
to_lat: number | null
to_lon: number | null
distance_km: number
travel_time_seconds: number
transport_type: string | null
}
export interface RoutePath {
total_distance_km: number
total_time_seconds: number
stages: RouteStage[]
}
function buildStage(fromDoc: ArangoDoc | undefined, toDoc: ArangoDoc | undefined, transportType: string, edges: ArangoDoc[]): RouteStage {
const distance = edges.reduce((s, e) => s + (e.distance_km || 0), 0)
const time = edges.reduce((s, e) => s + (e.travel_time_seconds || 0), 0)
return {
from_uuid: fromDoc?._key ?? null,
from_name: fromDoc?.name ?? null,
from_lat: fromDoc?.latitude ?? null,
from_lon: fromDoc?.longitude ?? null,
to_uuid: toDoc?._key ?? null,
to_name: toDoc?.name ?? null,
to_lat: toDoc?.latitude ?? null,
to_lon: toDoc?.longitude ?? null,
distance_km: distance,
travel_time_seconds: time,
transport_type: transportType,
}
}
export function buildRouteFromEdges(pathEdges: [string, string, ArangoDoc][], nodeDocs: Map<string, ArangoDoc>): RoutePath | null {
if (!pathEdges.length) return null
// Filter offer edges — not transport stages
const filtered = pathEdges.filter(([, , e]) => e.transport_type !== 'offer')
if (!filtered.length) return null
const stages: RouteStage[] = []
let currentEdges: ArangoDoc[] = []
let currentType: string | null = null
let segmentStart: string | null = null
for (const [fromKey, , edge] of filtered) {
const edgeType = edge.transport_type as string
if (currentType === null) {
currentType = edgeType
currentEdges = [edge]
segmentStart = fromKey
} else if (edgeType === currentType) {
currentEdges.push(edge)
} else {
stages.push(buildStage(nodeDocs.get(segmentStart!), nodeDocs.get(fromKey), currentType, currentEdges))
currentType = edgeType
currentEdges = [edge]
segmentStart = fromKey
}
}
const lastTo = filtered[filtered.length - 1][1]
stages.push(buildStage(nodeDocs.get(segmentStart!), nodeDocs.get(lastTo), currentType!, currentEdges))
return {
total_distance_km: stages.reduce((s, st) => s + (st.distance_km || 0), 0),
total_time_seconds: stages.reduce((s, st) => s + (st.travel_time_seconds || 0), 0),
stages,
}
}

33
src/index.ts Normal file
View File

@@ -0,0 +1,33 @@
import express from 'express'
import cors from 'cors'
import { ApolloServer } from '@apollo/server'
import { expressMiddleware } from '@apollo/server/express4'
import * as Sentry from '@sentry/node'
import { typeDefs, resolvers } from './schema.js'
const PORT = parseInt(process.env.PORT || '8000', 10)
const SENTRY_DSN = process.env.SENTRY_DSN || ''
if (SENTRY_DSN) {
Sentry.init({
dsn: SENTRY_DSN,
tracesSampleRate: 0.01,
release: process.env.RELEASE_VERSION || '1.0.0',
environment: process.env.ENVIRONMENT || 'production',
})
}
const app = express()
app.use(cors({ origin: ['https://optovia.ru'], credentials: true }))
const server = new ApolloServer({ typeDefs, resolvers, introspection: true })
await server.start()
app.use('/graphql/public', express.json(), expressMiddleware(server) as unknown as express.RequestHandler)
app.get('/health', (_, res) => { res.json({ status: 'ok' }) })
app.listen(PORT, '0.0.0.0', () => {
console.log(`Geo server ready on port ${PORT}`)
console.log(` /graphql/public - public (no auth)`)
})

1027
src/schema.ts Normal file

File diff suppressed because it is too large Load Diff