Fabriq
1.x
Docs/Fabriq/Spatial
Open

Reading4 min
Updated2 Aug 2026
Sourcev1/(data-planes)/spatial.mdx

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.

Note

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 the geography cast, so RadiusM and DistanceM are 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)
Note

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.