The spatial plane stores geometries and answers proximity queries — "assets within 500 m of this point, nearest first." It is implemented in adapters/postgres on PostGIS, backed by a GiST index over the fabriq_geometries table. Like Vector, it is a direct-write port (not event-sourced): you write geometries explicitly and search them.
Geometry is exchanged as WKT + SRID — engine-neutral, covering point, line, and polygon. Consumers holding GeoJSON convert to WKT at the boundary.
type SpatialQuerier interface {
Upsert(ctx context.Context, entity, id string, geom Geometry, meta map[string]any) error
Within(ctx context.Context, q SpatialQuery, into any) error
Delete(ctx context.Context, entity, id string) error
}
type Geometry struct {
WKT string
SRID int
}Reach it through f.Spatial(). Every operation is tenant-scoped structurally — SET LOCAL app.tenant_id plus an explicit tenant_id predicate, and fabriq_geometries carries an RLS tenant_isolation policy — see Tenancy.
The spatial plane requires the PostGIS extension. Migration 0011 creates the extension and table only when PostGIS is available in the cluster (it probes first), so a non-PostGIS Postgres still migrates cleanly — f.Spatial() simply returns a not-configured port.
SRID: geographic vs planar01
The SRID you store and query with selects the distance metric:
SRID 4326(WGS-84 longitude/latitude) — distances and the radius predicate use thegeographycast, soRadiusMandDistanceMare true metres across the globe.Any other SRID (including
0) — planar geometry in the geometry's own units (treat as local/planar metres).
// geographic
p := query.Geometry{WKT: "POINT (-122.4194 37.7749)", SRID: 4326}
// local/planar (metres)
q := query.Geometry{WKT: "POINT Z (10 20 3)", SRID: 0}Upsert02
Upsert stores or replaces the geometry for (entity, id), with optional metadata alongside it. An empty WKT is rejected.
err := f.Spatial().Upsert(ctx, "asset", assetID, query.Geometry{
WKT: "POINT (-122.4194 37.7749)",
SRID: 4326,
}, map[string]any{
"site_id": siteID,
"kind": "pump",
})The row is keyed on (tenant_id, entity, id): re-upserting the same id replaces the geometry and metadata.
Within03
Within returns entities whose geometry lies within RadiusM of Center, nearest first, scanned into *[]query.SpatialMatch.
type SpatialQuery struct {
Entity string
Center Geometry
RadiusM float64 // radius in metres
K int // cap; <= 0 → adapter default
}
type SpatialMatch struct {
ID string
DistanceM float64 // metres
Meta map[string]any
}var hits []query.SpatialMatch
err := f.Spatial().Within(ctx, query.SpatialQuery{
Entity: "asset",
Center: query.Geometry{WKT: "POINT (-122.42 37.77)", SRID: 4326},
RadiusM: 500,
K: 20,
}, &hits)
if err != nil {
return err
}
for _, h := range hits {
// h.ID is the aggregate id; h.DistanceM is metres from Center (nearest first);
// h.Meta is the metadata stored at Upsert.
}ST_DWithin (GiST-accelerated) bounds the candidate set, then results are ordered by ST_Distance — ordering on the true distance rather than the <-> KNN operator avoids degree-vs-metre disagreement across latitudes for SRID 4326. K defaults to the adapter default when unset. Results are scoped to the (tenant, entity) pair.
Delete04
Delete removes the geometry for (entity, id). It is idempotent — deleting an absent geometry is not an error.
err := f.Spatial().Delete(ctx, "asset", assetID)h.ID is the aggregate id you passed to Upsert. To get the full row, hydrate from Postgres with Relational().GetMany("asset", ids, &assets) — one batched query for the whole match set.
Testing05
fabriqtest.FakeSpatial is an exact in-memory geometry store implementing SpatialQuerier (haversine for SRID 4326, Euclidean otherwise), so unit tests get nearest-first Within semantics without a PostGIS container.