Place and PostGIS: How Stone Maps Thinks About Location
Place and PostGIS: How Stone Maps Thinks About Location
"Place-aware" is in the tagline. That means location isn't a feature we bolt on — it's baked into the schema. Posts have coordinates. Stones have coordinates. The Emissary uses location patterns to decide when to speak. The map view shows you where other people have been.
All of this runs on PostGIS, the spatial extension for Postgres. Here's how we use it, and what we deliberately don't do with it.
The Schema
Location data appears in a few places.
Posts have a location column, stored as a PostGIS POINT:
location geography(POINT, 4326)
SRID 4326 is the WGS84 coordinate system — the same one your phone's GPS uses. Longitude first, then latitude, which is backwards from the intuitive lat, lng convention but correct for POINT(lng lat) in Well-Known Text.
Stones can have a premappedLocationId — a reference to a locations table row with a name, lat, and lng. This is for stones that ship already situated at a specific place, before anyone has ever held them.
Users also have locationLat and locationLng columns — simple real floats for the user's home base or approximate location. These aren't spatial columns; they're just numbers. We haven't needed geospatial queries on user locations yet.
Nearby Posts
The core spatial query is in GET /api/map/nearby. It finds all public posts within a given radius of a coordinate:
const userPoint = `POINT(${lng} ${lat})`;
const results = await db.query.posts.findMany({
where: and(
isNotNull(posts.location),
sql`ST_DWithin(
${posts.location}::geography,
ST_GeomFromText(${userPoint}, 4326)::geography,
${radiusMeters}
)`
),
limit,
});
ST_DWithin on geography columns calculates distances in meters. The cast ::geography is what makes PostGIS treat the data as a curved earth calculation rather than a flat-plane approximation. For a journaling app where posts might span a city block or a continent, the accuracy matters.
The default radius is 1000 meters. The MCP tool exposes this as radiusMeters with a max of 50km.
Reading Points Back Out
PostGIS returns geometry values in binary or WKT format, not as plain lat/lng pairs. When we read posts back from the database, we parse the POINT(lng lat) string:
const locationMatch = post.location?.toString().match(/POINT\(([^ ]+) ([^ ]+)\)/);
const location = locationMatch && locationMatch[1] && locationMatch[2]
? { lng: parseFloat(locationMatch[1]), lat: parseFloat(locationMatch[2]) }
: undefined;
This is a regex on a WKT string, which is not glamorous. The better approach would be using ST_AsGeoJSON() or ST_X() / ST_Y() in the select to get clean numbers. We haven't made that change yet — the regex works, and we haven't hit the edge cases where it wouldn't.
The Bounding Box Query
The map view uses a different endpoint: GET /api/map/posts. Instead of a radius, it takes a bounding box — north, south, east, west — which is how Mapbox GL reports the current viewport. This means as you pan and zoom the map, the visible posts update efficiently:
ST_Within(
location::geometry,
ST_MakeEnvelope(west, south, east, north, 4326)
)
Bounding box queries are faster than radius queries for viewport-scale use. The tradeoff is that corners of the bounding box are farther from the center than a circle would be — you see some posts that are technically off-screen. For a map interface this is fine; the user is about to pan there anyway.
Location in the Emissary
The most interesting use of location is in shouldPromptUser(). When the Emissary checks whether to send a location_return prompt, it looks at the last 10 geotagged posts for the user and looks for a cluster of 3+ posts within about 500 meters:
const CLUSTER_RADIUS_DEG = 0.005; // ~500m at mid-latitudes
This is done in application code rather than SQL — we pull the points and do the clustering in TypeScript. It's not the most efficient approach, but it's simple, and at 10 posts per user it doesn't need to be efficient. A proper geospatial clustering query would look like ST_ClusterWithin or a K-means approach, but that's not worth the complexity for a trigger evaluation that runs once per user per polling cycle.
What We're Not Doing
Privacy is the main constraint on how far we push location features.
We don't store continuous location history. Location is attached to posts — it's only recorded when the user explicitly shares something. You can journal from a place without telling us where you are; the location field is always optional.
We don't do location tracking, geofencing, or ambient location collection. The app requests location when you create a post. If you decline, the post is created without coordinates. Nothing breaks.
We don't yet have distance between users or "other people near me" features. The map shows public posts geographically, but it doesn't tell you that another Stone Maps user is 400 meters away from you right now. That feels like a different kind of product.
The philosophy of slow over fast applies to location as much as anything else. A place matters because you were there at a particular moment, not because your phone knows where you've been every minute of the day.