PostGIS (geography / geometry)
Postgres-native geography/geometry columns, geometry constructors, spatial predicates (ST_DWithin, ST_Within, ST_Contains, ST_Intersects, ST_Buffer, bbox), ST_Distance projection + <-> KNN ordering for location-aware apps. Postgres only.
The @voltro/plugin-postgis package adds first-class PostGIS columns
and spatial operators to the Voltro schema DSL. Postgres only.
PostGIS is a postgres extension; other dialects have no portable
equivalent, so schema emission (voltro migrate / auto-migrate)
FAILS LOUD with a postgres-only error on non-postgres backends,
and spatial predicates throw in the SQL compiler.
Install + enable
pnpm add @voltro/plugin-postgisOn postgres, the migrator emits a one-time
CREATE EXTENSION IF NOT EXISTS postgis; alongside the first spatial
column's DDL. If your database user lacks the privilege to create
extensions, run that statement once per database as a superuser
before migrating.
Geography vs geometry
| Type | Coordinate system | Distance calculation | When to use |
|---|---|---|---|
geography(...) |
Spheroidal (curves with the earth) | Great-circle | Global apps, WGS-84 lat/lon (SRID 4326). Easy mental model. |
geometry(...) |
Planar / projected | Straight-line in the SRID's frame | Local apps with projected coords (UTM zone, state plane). Faster + works with non-spherical analysis. |
If you're unsure: pick geography with SRID 4326. It's the
default mental model for "GPS coordinates" and the distance
calculations are correct for any global scale.
Declare a location column
import { geography, point } from '@voltro/plugin-postgis'
import { id, text, table, queryFor } from '@voltro/database'
export const venues = table('venues', {
id: id(),
name: text(),
location: geography('Point', 4326), // lon/lat
})Insert a row using the point() helper to build the EWKT literal:
await ctx.store.insert('venues', {
name: 'Café Mockup',
location: point(8.682, 50.110, 4326), // Frankfurt
})The literal serialises as SRID=4326;POINT(8.682 50.11), which
postgres parses into the geography column via implicit cast.
Query within a radius
import { ST_DWithin, point } from '@voltro/plugin-postgis'
import { queryFor } from '@voltro/database'
const nearMe = await ctx.store.query(
queryFor(venues)
.where(ST_DWithin('location', point(8.68, 50.11), 1000)) // 1 km
.descriptor,
)ST_DWithin(column, point, meters) is the canonical "find rows
within X distance" predicate. PostGIS handles the great-circle math.
Query inside a polygon
import { ST_Intersects, polygon } from '@voltro/plugin-postgis'
import { queryFor } from '@voltro/database'
const cityCenter = polygon([
[[8.65, 50.10], [8.72, 50.10], [8.72, 50.13], [8.65, 50.13], [8.65, 50.10]],
])
const inside = await ctx.store.query(
queryFor(venues)
.where(ST_Intersects('location', cityCenter)) // venue location ∈ polygon
.descriptor,
)Every predicate takes the column first, then the WKT geometry:
ST_DWithin(column, geom, meters)— within a metric radius.ST_Within(column, geom)— the column's geometry is fully insidegeom(the right shape for "point column inside a polygon").ST_Contains(column, geom)— the column's geometry fully containsgeom(e.g. a regionboundarycolumn containing a point).ST_Intersects(column, geom)— any shared point.ST_Buffer(column, geom, meters)— the column intersectsgeomgrown bymeters(a metric buffer; shape-aware "within N metres").bboxOverlaps(column, geom)— the&&bounding-box overlap operator: cheap, GiST-index-only, approximate. Build the box withenvelope(minLon, minLat, maxLon, maxLat, srid).
Spatial predicates work in one-shot ctx.store.query reads only;
they throw in the in-memory reactive matcher, so keep them out of
subscriptions.
Geometry constructors
point / lineString / polygon / multiPoint /
multiLineString / multiPolygon build EWKT literals; geoJson
lowers a GeoJSON geometry to the same EWKT (SRID 4326 by the GeoJSON
spec). Coordinates are [lon, lat] (x before y) throughout.
import { point, lineString, polygon, multiPolygon, geoJson } from '@voltro/plugin-postgis'
const route = lineString([[8.68, 50.11], [8.69, 50.12]], 4326)
const zones = multiPolygon([[[[8.6, 50.1], [8.7, 50.1], [8.7, 50.2], [8.6, 50.1]]]], 4326)
const fromGeoJson = geoJson({ type: 'Point', coordinates: [8.682, 50.110] })Surfacing distance + nearest-neighbour ordering
withDistance(...) projects ST_Distance(column, geom) as a
selectable column (metric metres by default), and nearestBy(...)
adds a <-> KNN ORDER BY for indexed nearest-neighbour search.
Both are QueryTransforms applied via the builder's .use(...) seam
(the same mechanism as hybridSearch), so they compose with
.where(...) and .limit(k). Postgres-only.
import { withDistance, nearestBy, ST_DWithin, point } from '@voltro/plugin-postgis'
import { queryFor } from '@voltro/database'
// Distance-as-a-value in metres.
const withMeters = await ctx.store.query(
queryFor(venues)
.use(withDistance('location', point(8.68, 50.11), { as: 'meters' }))
.where(ST_DWithin('location', point(8.68, 50.11), 5000))
.descriptor,
)
// The 5 nearest venues via the indexed `<->` operator.
const nearest5 = await ctx.store.query(
queryFor(venues).use(nearestBy('location', point(8.68, 50.11))).limit(5).descriptor,
)withDistance(column, geom, { as?, useGeography? }) aliases the
distance column (default distance); nearestBy(column, geom, { direction? }) leads any explicit .orderBy(...). <-> is planar
(postgres has no geography <->), so KNN ranks by planar distance —
add withDistance when you need a metric value.
Indexes
GiST is the PostGIS-recommended index access method for
geometry/geography columns. The framework's regular .index([...])
uses B-tree, which is useless for spatial queries — declare the
index with kind: 'gist' on .expressionIndex():
export const venues = table('venues', {
id: id(),
name: text(),
location: geography('Point', 4326),
}).expressionIndex(
'venues_loc_gist',
[{ expr: '"location"' }],
{ kind: 'gist' },
)The framework emits:
CREATE INDEX IF NOT EXISTS "venues_loc_gist"
ON "venues" USING GIST (("location"));on postgres. On every other dialect the migrator warns + falls back to a plain B-tree index (spatial queries will then fall back to sequential scan — there's no portable equivalent of GiST).
Combine kind: 'gist' with where: … for a partial GiST index:
.expressionIndex(
'active_venues_loc_gist',
[{ expr: '"location"' }],
{ kind: 'gist', where: `"active" = true` },
)Cross-dialect
PostGIS is a postgres extension. A schema using geography() /
geometry() fails loud at schema emission when the active dialect
isn't postgres — no silent TEXT fallback, no graceful degradation.
Apps that need spatial queries on
MySQL/MariaDB/MSSQL/SQLite should either:
- Use the dialect's native spatial type (mysql's
POINT/GEOMETRY, mssql'sgeography/geometry) via a file-based migration + raw SQL - Store coords as numeric columns + filter app-side (acceptable up to ~100k rows)
Voltro doesn't ship a cross-dialect spatial abstraction — the behavioural gaps are too large to paper over.
See also
- Columns — the regular schema-DSL types
- Expression indexes — the
framework's index API (
kind: 'gist' | 'gin'on postgres) - PostGIS docs — the official manual, authoritative for every spatial function the framework re-exports