Compare commits

...
3 Commits
Author SHA1 Message Date
Ruslan Bakiev 7da13c895d Provision configured Geo database
Build and deploy Docker image / build (push) Successful in 31s
2026-08-20 17:33:17 +07:00
Ruslan Bakiev f2c2f7f99f Use provisioned Geo database
Build and deploy Docker image / build (push) Successful in 47s
2026-08-20 17:30:25 +07:00
Ruslan Bakiev 32f7e480e3 Restore Africa navigation graph
Build and deploy Docker image / build (push) Successful in 1m18s
2026-08-20 17:23:31 +07:00
8 changed files with 1981 additions and 6 deletions
+2 -1
View File
@@ -25,7 +25,8 @@ COPY --from=runtime-deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
COPY scripts ./scripts COPY scripts ./scripts
COPY data ./data
EXPOSE 8000 EXPOSE 8000
CMD ["sh", "-c", ". /app/scripts/load-vault-env.sh && node dist/index.js"] CMD ["sh", "-c", ". /app/scripts/load-vault-env.sh && npm run seed:reference && node dist/index.js"]
+17
View File
@@ -0,0 +1,17 @@
product,hs6,label,note
Cocoa Beans,180100,"Cocoa beans; whole or broken, raw or roasted",
Shea Butter,151590,Fixed vegetable fats and oils; other (often used for shea butter),best-fit; shea butter not explicit in HS text
Cashew Nuts,080131,"Cashew nuts, fresh or dried, in shell",
Cashew Nuts,080132,"Cashew nuts, fresh or dried, shelled",
Palm Oil,151110,"Palm oil and its fractions, crude",
Palm Oil,151190,"Palm oil and its fractions, other than crude",
Coffee Beans,090111,Coffee; not roasted or decaffeinated,
Coffee Beans,090112,"Coffee; decaffeinated, not roasted",
Sesame Seeds,120740,"Sesamum seeds, whether or not broken",
Cotton,520100,Cotton; not carded or combed,
Maize,100590,"Maize (corn), other than seed",
Maize,100510,"Maize (corn), seed",
Sorghum,100700,Grain sorghum,
Natural Rubber,400110,"Natural rubber latex, in primary forms",
Natural Rubber,400122,Technically specified natural rubber (TSNR),
Natural Rubber,400129,"Natural rubber, other than latex/TSNR/smoked sheets",
1 product hs6 label note
2 Cocoa Beans 180100 Cocoa beans; whole or broken, raw or roasted
3 Shea Butter 151590 Fixed vegetable fats and oils; other (often used for shea butter) best-fit; shea butter not explicit in HS text
4 Cashew Nuts 080131 Cashew nuts, fresh or dried, in shell
5 Cashew Nuts 080132 Cashew nuts, fresh or dried, shelled
6 Palm Oil 151110 Palm oil and its fractions, crude
7 Palm Oil 151190 Palm oil and its fractions, other than crude
8 Coffee Beans 090111 Coffee; not roasted or decaffeinated
9 Coffee Beans 090112 Coffee; decaffeinated, not roasted
10 Sesame Seeds 120740 Sesamum seeds, whether or not broken
11 Cotton 520100 Cotton; not carded or combed
12 Maize 100590 Maize (corn), other than seed
13 Maize 100510 Maize (corn), seed
14 Sorghum 100700 Grain sorghum
15 Natural Rubber 400110 Natural rubber latex, in primary forms
16 Natural Rubber 400122 Technically specified natural rubber (TSNR)
17 Natural Rubber 400129 Natural rubber, other than latex/TSNR/smoked sheets
+320
View File
@@ -0,0 +1,320 @@
[
{
"lei": "8945006MNLG7IEZZP361",
"name": "TCHIN LAIT",
"countryCode": "DZ",
"city": "BEJAIA"
},
{
"lei": "894500MUZEAR4S9VUF19",
"name": "Fundo de Pensões da ANPG",
"countryCode": "AO",
"city": "Luanda"
},
{
"lei": "254900FAFYBRYHYVQK50",
"name": "BENIN SUKUK",
"countryCode": "BJ",
"city": "Cotonou"
},
{
"lei": "254900AMO5ZG20K1RC67",
"name": "The Genoril Family Foundation Trust",
"countryCode": "BW",
"city": "Gaborone"
},
{
"lei": "2549006CD5SKSN5P3R39",
"name": "Coris Holding",
"countryCode": "BF",
"city": "Ouagadougou"
},
{
"lei": "2138005FD9MF6EWMPQ38",
"name": "FINBANK S.A",
"countryCode": "BI",
"city": "BUJUMBURA"
},
{
"lei": "894500K8QR1WMIRPTI48",
"name": "Banco Interatlântico, SA",
"countryCode": "CV",
"city": "Santiago"
},
{
"lei": "2549004XTX9UX53WO914",
"name": "KAPPA PAY CAMEROON S.A",
"countryCode": "CM",
"city": "Douala"
},
{
"lei": "213800EV35S633NZGJ73",
"name": "INTERIM IMPEX LTD",
"countryCode": "CF",
"city": "BELIZE CITY"
},
{
"lei": "254900CLP4QJ5K9IQZ60",
"name": "FONDATION SAINT LUC",
"countryCode": "TD",
"city": "N'Djamena"
},
{
"lei": "9845007F980D99B5B843",
"name": "Office de radio et télévision des Comores",
"countryCode": "KM",
"city": "Hahaia"
},
{
"lei": "254900QFQHP2H1A18H13",
"name": "SOFIBANQUE SA",
"countryCode": "CD",
"city": "Kinshasa"
},
{
"lei": "894500L731TNWUALQL85",
"name": "Suisse Capital Bank",
"countryCode": "CG",
"city": "kinshasa"
},
{
"lei": "2549002ZE6ZX3NJ24F25",
"name": "NSIA BANQUE COTE D'IVOIRE",
"countryCode": "CI",
"city": "ABIDJAN"
},
{
"lei": "213800AQZACUB1GRIO16",
"name": "INTERNATIONAL INVESTMENT BANK (DJIBOUTI) SA",
"countryCode": "DJ",
"city": "DJIBOUTI"
},
{
"lei": "894500LXVN8OVSBU5Z07",
"name": "ORASCOM DEVELOPMENT EGYPT S.A.E.",
"countryCode": "EG",
"city": "Cairo"
},
{
"lei": "8945006B93MEG50VOY83",
"name": "NOMEX SA",
"countryCode": "GQ",
"city": "MALABO"
},
{
"lei": "213800QPN8GIV3HE7Y16",
"name": "OLD MUTUAL LIFE ASSURANCE COMPANY (ESWATINI) LIMITED",
"countryCode": "SZ",
"city": "MBABANE"
},
{
"lei": "254900ODL6T3024W7T26",
"name": "Sahal Microfinance Institution S.C.",
"countryCode": "ET",
"city": "Jigjiga"
},
{
"lei": "8945008IV1TVEZK9ZE10",
"name": "BATI INVEST DES TRAVAUX PUBLICS",
"countryCode": "GA",
"city": "LIBREVILLE"
},
{
"lei": "254900UML6VPSEXUL552",
"name": "Sunu Pesa Company Ltd",
"countryCode": "GM",
"city": "BANJUL"
},
{
"lei": "894500A44URWYLDKDZ48",
"name": "Quality Insurance PLC",
"countryCode": "GH",
"city": "Accra"
},
{
"lei": "254900EGRWJ18XJTG823",
"name": "Diama Bank SA",
"countryCode": "GN",
"city": "Conakry"
},
{
"lei": "5493000AUJHJ637VBW34",
"name": "MYA GB SA",
"countryCode": "GW",
"city": "BISSAU"
},
{
"lei": "254900HMCSYV1XUOLH30",
"name": "GULF AFRICAN BANK LIMITED",
"countryCode": "KE",
"city": "NAIROBI"
},
{
"lei": "549300OSGF21ECPL5U94",
"name": "PUBLIC OFFICERS' DEFINED CONTRIBUTION PENSION FUND",
"countryCode": "LS",
"city": "MASERU"
},
{
"lei": "213800KIDQUEU39CA834",
"name": "DRAKE II INVESTMENT CORP.",
"countryCode": "LR",
"city": "MONROVIA"
},
{
"lei": "254900SW9BNEN3IW0606",
"name": "Alsanam International Company for the Import of Transport Means and Their Accessories LLC",
"countryCode": "LY",
"city": "Tripoli"
},
{
"lei": "254900R1V61IGBAHBH58",
"name": "ACEP Madagascar S.A",
"countryCode": "MG",
"city": "Antananarivo"
},
{
"lei": "254900M75MKKXRG8UJ24",
"name": "General Alliance Insruance Limited",
"countryCode": "MW",
"city": "Blantyre"
},
{
"lei": "2549004OGQDW7Z0YJK76",
"name": "APS SARL",
"countryCode": "ML",
"city": "Bamako"
},
{
"lei": "2549003R3WIEWCSWMQ96",
"name": "CLEAR ECOLO CLEANER-SARL",
"countryCode": "MR",
"city": "Nouakchott"
},
{
"lei": "984500UF141X3980DB32",
"name": "Schroders Capital Private Equity Asia Mauritius X Limited",
"countryCode": "MU",
"city": "TRIANON"
},
{
"lei": "254900EXH5CAH7F1M005",
"name": "Amazon Data Services Morocco S.A.R.L.",
"countryCode": "MA",
"city": "Rabat"
},
{
"lei": "254900LEOSX6Y17RPE51",
"name": "ACCESS BANK MOZAMBIQUE, SA",
"countryCode": "MZ",
"city": "Maputo"
},
{
"lei": "254900G8YMA6ZH4G8R09",
"name": "Namibia Power Corporation (Proprietary) Limited",
"countryCode": "NA",
"city": "Windhoek"
},
{
"lei": "2549004LW4WH2ME3K171",
"name": "MDT SECURITE",
"countryCode": "NE",
"city": "Niamey"
},
{
"lei": "984500AF636E5F6B5D95",
"name": "CHIRON INDUSTRIES INTERNATIONAL LIMITED",
"countryCode": "NG",
"city": "Ikeja"
},
{
"lei": "254900ZPUOPS6SOW2639",
"name": "AMAZON DATA SERVICES RWANDA LIMITED",
"countryCode": "RW",
"city": "Kigali"
},
{
"lei": "254900BV6GST7KYEKK92",
"name": "GTI Bank Sarl",
"countryCode": "ST",
"city": "AGUA GRANDE"
},
{
"lei": "9845000AF5YF96CAD954",
"name": "PHOENIX SENEGAL",
"countryCode": "SN",
"city": "DAKAR"
},
{
"lei": "213800T7QXLMXOPGNH26",
"name": "ALLIANCE MARKETING GROUP INC.",
"countryCode": "SC",
"city": "MAHE"
},
{
"lei": "254900S70K55I24R6A40",
"name": "BLOOM BANK AFRICA SIERRA LEONE LIMITED",
"countryCode": "SL",
"city": "FREETOWN"
},
{
"lei": "213800JAG5A5QL1PA391",
"name": "SOMALIA REPUBLIC WIDOWS AND ORPHANS PENSION FUND",
"countryCode": "SO",
"city": "MOGADISHU"
},
{
"lei": "213800UE2YQO9NQDF884",
"name": "TEREBINTH TOTAL RETURN FUND EN COMMANDITE PARTNERSHIP",
"countryCode": "ZA",
"city": "BELLVILLE"
},
{
"lei": "894500TQ3YLYPWW51276",
"name": "Charter One Bank",
"countryCode": "SS",
"city": "Juba"
},
{
"lei": "254900MWS2H21N14TX74",
"name": "مكتب الإيثار للإستخدام الخارجي",
"countryCode": "SD",
"city": "Khartoum"
},
{
"lei": "9845003EADEAAB51B135",
"name": "FLAMINGO COMPANY LIMITED",
"countryCode": "TZ",
"city": "Kinondoni"
},
{
"lei": "254900R6Y1CBT4SMXO83",
"name": "ODS - CONSEIL",
"countryCode": "TG",
"city": "Lome"
},
{
"lei": "4117KJVBXU8AY69N0912",
"name": "Delta Express Line",
"countryCode": "TN",
"city": "Rades"
},
{
"lei": "984500864K9557746371",
"name": "Brewfinity Uganda Limited",
"countryCode": "UG",
"city": "KAMPALA"
},
{
"lei": "9845000A7DCXBD946O23",
"name": "VISAKA MINING AND MINERALS ZAMBIA LIMITED",
"countryCode": "ZM",
"city": "LUSAKA"
},
{
"lei": "254900X407BUVRO26U77",
"name": "Delta Gold Zimbabwe (Private) Limited",
"countryCode": "ZW",
"city": "Harare"
}
]
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -6,7 +6,8 @@
"build": "tsc", "build": "tsc",
"start": "node dist/index.js", "start": "node dist/index.js",
"dev": "tsx --watch src/index.ts", "dev": "tsx --watch src/index.ts",
"seed:demo": "tsx scripts/seed-demo.ts" "seed:demo": "tsx scripts/seed-demo.ts",
"seed:reference": "tsx scripts/seed-reference.ts"
}, },
"dependencies": { "dependencies": {
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
+241
View File
@@ -0,0 +1,241 @@
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { ensureGraph, getDb } from '../dist/db.js'
type SupplierSeed = { lei: string; name: string; countryCode: string; city: string }
type HubSeed = {
code: string
name: string
countryCode: string
latitude: number
longitude: number
functionCode: string
subdivision: string
}
type ProductSeed = { name: string; hsCodes: string[]; label: string }
const countryNames = new Intl.DisplayNames(['en'], { type: 'region' })
const productImages = [
'https://images.unsplash.com/photo-1625246333195-78d9c38ad449?auto=format&fit=crop&w=900&q=80',
'https://images.unsplash.com/photo-1574323347407-f5e1ad6d020b?auto=format&fit=crop&w=900&q=80',
'https://images.unsplash.com/photo-1592924357228-91a4daadcfea?auto=format&fit=crop&w=900&q=80',
]
function parseCsvLine(line: string): string[] {
const fields: string[] = []
let field = ''
let quoted = false
for (let index = 0; index < line.length; index += 1) {
const character = line[index]
if (character === '"') {
if (quoted && line[index + 1] === '"') {
field += '"'
index += 1
} else {
quoted = !quoted
}
} else if (character === ',' && !quoted) {
fields.push(field)
field = ''
} else {
field += character
}
}
if (quoted) throw new Error(`Unclosed CSV quote: ${line}`)
fields.push(field)
return fields
}
function stableUuid(namespace: string, value: string): string {
const hex = createHash('sha256').update(`${namespace}:${value}`).digest('hex')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`
}
function normalize(value: string): string {
return value.normalize('NFKD').replace(/[^a-zA-Z0-9]/g, '').toLowerCase()
}
function readSuppliers(): SupplierSeed[] {
const suppliers = JSON.parse(
readFileSync(new URL('../data/africa_suppliers.json', import.meta.url), 'utf8'),
) as SupplierSeed[]
for (const supplier of suppliers) {
if (!supplier.lei || !supplier.name || supplier.countryCode.length !== 2) {
throw new Error(`Invalid Africa supplier reference: ${JSON.stringify(supplier)}`)
}
}
return suppliers
}
function readHubs(): HubSeed[] {
const csv = readFileSync(
new URL('../data/unlocode_africa_road_terminals.csv', import.meta.url),
'utf8',
)
return csv.trim().split(/\r?\n/).slice(1).map((line) => {
const [code, name, countryCode, latitude, longitude, functionCode, subdivision] = parseCsvLine(line)
const parsedLatitude = Number(latitude)
const parsedLongitude = Number(longitude)
if (!code || !name || countryCode.length !== 2 || !Number.isFinite(parsedLatitude) || !Number.isFinite(parsedLongitude)) {
throw new Error(`Invalid UN/LOCODE row: ${line}`)
}
return { code, name, countryCode, latitude: parsedLatitude, longitude: parsedLongitude, functionCode, subdivision }
})
}
function readProducts(): ProductSeed[] {
const csv = readFileSync(new URL('../data/africa_products.csv', import.meta.url), 'utf8')
const grouped = new Map<string, ProductSeed>()
for (const line of csv.trim().split(/\r?\n/).slice(1)) {
const [name, hsCode, label] = parseCsvLine(line)
if (!name || !/^\d{6}$/.test(hsCode) || !label) throw new Error(`Invalid product reference: ${line}`)
const current = grouped.get(name)
if (current === undefined) grouped.set(name, { name, hsCodes: [hsCode], label })
else current.hsCodes.push(hsCode)
}
return [...grouped.values()]
}
function transportTypes(functionCode: string): string[] {
const values: string[] = []
if (functionCode.includes('1')) values.push('sea')
if (functionCode.includes('2')) values.push('rail')
if (functionCode.includes('3')) values.push('auto')
if (values.length === 0) throw new Error(`Unsupported UN/LOCODE function: ${functionCode}`)
return values
}
function supplierHub(supplier: SupplierSeed, hubs: HubSeed[]): HubSeed {
const countryHubs = hubs.filter((hub) => hub.countryCode === supplier.countryCode)
if (countryHubs.length === 0) throw new Error(`No Africa hub for ${supplier.countryCode}`)
const city = normalize(supplier.city)
const cityHub = city.length > 0
? countryHubs.find((hub) => normalize(hub.name).includes(city) || city.includes(normalize(hub.name)))
: undefined
return cityHub ?? countryHubs[0]
}
async function upsertDocuments(collection: string, documents: Record<string, unknown>[]) {
const batchSize = 250
for (let offset = 0; offset < documents.length; offset += batchSize) {
await getDb().query(
`FOR document IN @documents
UPSERT { _key: document._key }
INSERT document
UPDATE document
IN @@collection`,
{ documents: documents.slice(offset, offset + batchSize), '@collection': collection },
)
}
}
await ensureGraph()
const timestamp = new Date().toISOString()
const suppliers = readSuppliers()
const hubs = readHubs()
const products = readProducts()
const supplierHubs = new Map(suppliers.map((supplier) => [supplier.lei, supplierHub(supplier, hubs)]))
const priorityHubCodes = new Set([...supplierHubs.values()].map((hub) => hub.code))
for (const hub of hubs) {
if (!hubs.some((candidate) => candidate.countryCode === hub.countryCode && priorityHubCodes.has(candidate.code))) {
priorityHubCodes.add(hub.code)
}
}
const hubNodes = hubs.map((hub) => {
const country = countryNames.of(hub.countryCode)
if (country === undefined) throw new Error(`Unknown country code: ${hub.countryCode}`)
return {
_key: `africa-${hub.code.toLowerCase()}`,
node_type: 'logistics',
name: hub.name,
latitude: hub.latitude,
longitude: hub.longitude,
country,
country_code: hub.countryCode,
transport_types: transportTypes(hub.functionCode),
navigation_priority: priorityHubCodes.has(hub.code) ? 1 : 0,
source: 'UN/LOCODE Africa',
locode: hub.code,
function_code: hub.functionCode,
subdivision: hub.subdivision,
synced_at: timestamp,
created_at: timestamp,
}
})
const supplierNodes = suppliers.map((supplier, index) => {
const hub = supplierHubs.get(supplier.lei)
if (hub === undefined) throw new Error(`Supplier hub is missing: ${supplier.lei}`)
const country = countryNames.of(supplier.countryCode)
if (country === undefined) throw new Error(`Unknown country code: ${supplier.countryCode}`)
return {
_key: stableUuid('africa-supplier', supplier.lei),
node_type: 'supplier',
name: supplier.name,
latitude: hub.latitude + ((index % 5) - 2) * 0.012,
longitude: hub.longitude + ((Math.floor(index / 5) % 5) - 2) * 0.012,
country,
country_code: supplier.countryCode,
supplier_logo_url: `https://ui-avatars.com/api/?name=${encodeURIComponent(supplier.name)}&background=0B6BFF&color=fff&bold=true`,
lei: supplier.lei,
city: supplier.city,
synced_at: timestamp,
created_at: timestamp,
}
})
const offerNodes: Record<string, unknown>[] = []
const offerEdges: Record<string, unknown>[] = []
for (const [supplierIndex, supplier] of suppliers.entries()) {
const hub = supplierHubs.get(supplier.lei)
if (hub === undefined) throw new Error(`Supplier hub is missing: ${supplier.lei}`)
const supplierNode = supplierNodes[supplierIndex]
for (let productOffset = 0; productOffset < 2; productOffset += 1) {
const product = products[(supplierIndex * 2 + productOffset) % products.length]
const productIndex = products.indexOf(product)
const quoteUuid = stableUuid('africa-quote', `${supplier.lei}:${product.name}`)
const offer = {
_key: quoteUuid,
node_type: 'offer',
name: `${supplier.name}${product.name}`,
latitude: Number(supplierNode.latitude) + (productOffset === 0 ? -0.006 : 0.006),
longitude: Number(supplierNode.longitude) + (productOffset === 0 ? 0.006 : -0.006),
country: supplierNode.country,
country_code: supplier.countryCode,
product_uuid: stableUuid('africa-product', product.name),
product_name: product.name,
product_image_url: productImages[productIndex % productImages.length],
supplier_uuid: supplierNode._key,
supplier_name: supplier.name,
supplier_logo_url: supplierNode.supplier_logo_url,
price_per_unit: String(700 + (supplierIndex % 10) * 65 + productOffset * 40),
currency: 'USD',
quantity: String(250 + (supplierIndex % 12) * 50 + productOffset * 25),
unit: 'ton',
synced_at: timestamp,
created_at: timestamp,
}
offerNodes.push(offer)
offerEdges.push({
_key: `africa-offer-${quoteUuid}`,
_from: `nodes/africa-${hub.code.toLowerCase()}`,
_to: `nodes/${quoteUuid}`,
to_uuid: quoteUuid,
to_name: offer.name,
to_latitude: offer.latitude,
to_longitude: offer.longitude,
distance_km: 2 + productOffset,
travel_time_seconds: 900 + productOffset * 300,
transport_type: 'offer',
created_at: timestamp,
synced_at: timestamp,
})
}
}
await upsertDocuments('nodes', [...hubNodes, ...supplierNodes, ...offerNodes])
await upsertDocuments('edges', offerEdges)
console.log(`Seeded Africa geo reference data: hubs ${hubNodes.length}, suppliers ${supplierNodes.length}, offers ${offerNodes.length}, edges ${offerEdges.length}`)
+17 -3
View File
@@ -6,16 +6,30 @@ const ARANGODB_PASSWORD = process.env.ARANGODB_PASSWORD || ''
let _db: Database | null = null let _db: Database | null = null
function database(databaseName: string): Database {
const url = ARANGODB_URL.startsWith('http') ? ARANGODB_URL : `http://${ARANGODB_URL}`
return new Database({ url, databaseName, auth: { username: 'root', password: ARANGODB_PASSWORD } })
}
export function getDb(): Database { export function getDb(): Database {
if (!_db) { if (!_db) {
const url = ARANGODB_URL.startsWith('http') ? ARANGODB_URL : `http://${ARANGODB_URL}` _db = database(ARANGODB_DATABASE)
_db = new Database({ url, databaseName: ARANGODB_DATABASE, auth: { username: 'root', password: ARANGODB_PASSWORD } }) console.log(`Connected to ArangoDB: ${ARANGODB_URL}/${ARANGODB_DATABASE}`)
console.log(`Connected to ArangoDB: ${url}/${ARANGODB_DATABASE}`)
} }
return _db return _db
} }
async function ensureDatabase(): Promise<void> {
const system = database('_system')
const databases = await system.listDatabases()
if (!databases.includes(ARANGODB_DATABASE)) {
await system.createDatabase(ARANGODB_DATABASE)
console.log(`Created ArangoDB database: ${ARANGODB_DATABASE}`)
}
}
export async function ensureGraph(): Promise<void> { export async function ensureGraph(): Promise<void> {
await ensureDatabase()
const db = getDb() const db = getDb()
const nodes = db.collection('nodes') const nodes = db.collection('nodes')
+1 -1
View File
@@ -1096,7 +1096,7 @@ export const resolvers = {
FILTER @transport_type == null OR @transport_type IN types FILTER @transport_type == null OR @transport_type IN types
FILTER @country == null OR node.country == @country FILTER @country == null OR node.country == @country
${bounds.filter} ${bounds.filter}
SORT node.name ASC SORT node.navigation_priority DESC, node.name ASC
LIMIT @offset, @limit LIMIT @offset, @limit
RETURN node RETURN node
`, { transport_type: args.transportType ?? null, country: args.country ?? null, offset: args.offset ?? 0, limit: args.limit ?? 50, ...bounds.vars }) `, { transport_type: args.transportType ?? null, country: args.country ?? null, offset: args.offset ?? 0, limit: args.limit ?? 50, ...bounds.vars })