Geo
A spatial index for "where is everything right now": you push entity positions into an index, then ask radius, bounding-box, and distance-between questions over the current snapshot. Geofences - circle and polygon shapes with an on-demand containment check - live on the same index.
When to reach for it: fleet and courier tracking, "show me drivers within 2 km", delivery and service-area checks, proximity matching, any feature that answers a question about current positions.
When not to: whether an entity is still alive and reporting belongs to Liveness; a durable history of where something has been belongs to Timeline - geo keeps only the latest position per entity and overwrites the previous one.
Concepts
- The index is the unit of ownership. Entities, fences, and queries
hang off one index id, and only its creator can touch it - everyone
else, platform admin tokens included, gets
403 unauthorized. There is no sharing mechanism, and the owner reads every position in the index, so an index of end users' locations belongs behind your own backend rather than in front of per-user tokens. - Positions are upserts under an id you choose.
update-locationcreates or replaces; there is no separate create call. - Longitude comes first, and storage is lossy. Coordinates are stored as a geohash, so what you read back differs from what you wrote by well under a metre.
unitgoverns the whole query, input and output. Despite the names,radius_metersis read in the unit you asked for anddistance_metersis reported in it.- Deactivating an index locks it. With
active: falseevery operation except listing andPATCHreturns400 index_inactive.
Create an index
snug geo create --name fleet --description "Fleet tracking index"
snug geo create --name fleet \
--max-radius-meters 50000 --default-limit 100 --max-limit 1000 \
--max-entities 100000 --entity-ttl-seconds 86400
snug --output json geo list --limit 5
snug geo update --id <index-id> --max-radius-meters 25000
snug geo delete-index --id <index-id> --forceThose five knobs are the only per-index tuning and all are optional.
max_radius_meters (50000) and max_limit (1000) bound queries,
default_limit (100) applies when a query omits limit, and
max_entities / entity_ttl_seconds are unset - unlimited and forever.
delete-index drops the index and all its positions, but not its
geofences, which survive as orphans; delete those first. list takes the
shared search grammar, scoped to indexes
you own.
Record positions
snug geo update-location --id <index-id> -e driver1 \
-x -122.4194 -y 37.7749 -m '{"status":"available","vehicle":"van"}'
snug geo batch-update-location --id <index-id> --file positions.json
snug geo get-entity --id <index-id> -e driver1
snug geo list-entities --id <index-id> --limit 100
snug geo delete-entity --id <index-id> -e driver1The CLI's --file takes a bare JSON array of
{entity_id, longitude, latitude, metadata?}; the HTTP endpoint wraps the
same array in {"entities": [...]}. A batch over 1000 entries is rejected
by request validation, and one over the index's max_limit with
400 batch_too_large.
An upsert replaces the whole record, so omitting metadata on a later
write clears what was there. Resend it on every position update, or keep
it somewhere movement does not overwrite:
snug geo update-location --id <index-id> -e driver1 -x -122.42 -y 37.775
snug --output json geo get-entity --id <index-id> -e driver1
# -> "metadata": {}Query
snug geo query-radius --id <index-id> -x -122.4194 -y 37.7749 -r 2000 -d -c
snug geo query-bbox --id <index-id> \
--min-lon -122.45 --min-lat 37.70 --max-lon -122.35 --max-lat 37.80 -c
snug geo distance --id <index-id> --from driver1 --to driver4 -u km
snug geo stats --id <index-id>Radius results come back nearest-first:
curl -s -H "Authorization: Bearer $SNUG_API_TOKEN" \
"http://localhost:4000/api/v1/geo/$INDEX/query/radius?longitude=-122.4194&latitude=37.7749&radius_meters=2000&limit=10"{
"status": 200,
"msg": "OK",
"data": {
"entities": [
{
"entity_id": "docs4-driver1",
"longitude": -122.41940170526505,
"latitude": 37.77490001056578,
"distance_meters": 0.1499,
"metadata": { "status": "available", "vehicle": "van" },
"last_updated": 1788007921
},
{
"entity_id": "docs4-driver2",
"longitude": -122.40889817476273,
"latitude": 37.78370056243101,
"distance_meters": 1345.5461,
"metadata": { "status": "busy" },
"last_updated": 1788007622
}
],
"query_center": { "longitude": -122.4194, "latitude": 37.7749 },
"query_bounds": null,
"total_found": 2,
"timestamp": 1788007921
}
}query_center is populated for radius queries and query_bounds for
bounding-box queries; the other is null. Note driver1: written at exactly
-122.4194, 37.7749, it reports 0.1499 metres from a query centred on
itself. That drift is the geohash resolution - never test a position you
just wrote for exact equality. distance between two stored entities is
the one call that names its unit honestly, returning
{"distance": 13.4419, "unit": "km"}.
Behaviors and gotchas
- The CLI hides coordinates unless you ask. Without
-c/-d,query-radiusandquery-bboxsendwith_coordinates=falseandwith_distance=false, and the fields come back aslongitude: 0.0, latitude: 0.0, distance_meters: null- zero being a real coordinate, this reads as the Gulf of Guinea, not as missing data. The HTTP API defaults both to true; only the CLI opts out. Pass-d -cwhenever you want positions back. limitis clamped,radius_metersis not.limit=5000against an index whosemax_limitis 1000 quietly returns at most 1000 rows, but a radius over the index cap is400 invalid_radius.- Timestamps use two scales. Entity
last_updatedand the querytimestampare epoch seconds; indexcreated_at/updated_atand the statslast_updateare epoch milliseconds. statsnever computesbounds- alwaysnull- andlast_updateis the index manifest's timestamp, which entity writes do not move. Only itsentity_countis counted live.- TTL and caps are enforced on write. With
entity_ttl_seconds: 3an entity was gone fromlist-entitiesand404 entity_not_foundfive seconds later; withmax_entities: 2, the third entity failed with400 max_entities_reached. - A deactivated index is not readable. After
PATCH {"active": false}, evenGET /api/v1/geo/{id}returns400 index_inactive. Onlylist(which still shows it) and anotherPATCHwork, soPATCH {"active": true}is the way back. - Bad geometry is rejected in the envelope: latitude outside
[-90, 90] or longitude outside [-180, 180] is
400 invalid_coordinates, an unknownunitis400 invalid_unit, and a box whose min is not strictly below its max is400 invalid_bounding_box.
Geofences
Circle and polygon shapes and the containment check have their own page: Geofences.
Limits and configuration
Every limit is per-index, set through config at create or update time:
the
Geo CONFIG reference
records that the service reads no GEO_* environment variables at all.