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` },
)Changing the SRID or the subtype
The subtype and the SRID are part of the schema, so the migration planner sees a change to either and the schema fingerprint moves with it. What it does about the change depends on whether a transform exists.
A changed SRID reprojects. ST_Transform moves every stored value
into the new frame exactly, so the operation applies like any other
schema change:
// before
location: geography('Point', 4326),
// after — planned as alter-column-type, applied with
// USING ST_Transform("location"::geometry, 25832)::geography
location: geography('Point', 25832),The rewrite touches every row in the table, so time it like any other
full-table operation. PostGIS refuses a projected target on a
geography column at all (Only lon/lat coordinate systems are supported in geography) — use geometry() for projected frames.
A changed subtype, or a change between geometry and geography,
is refused. A point is not a polygon and no expression makes it one,
so the planner blocks the operation rather than guessing. Acknowledge
it the same way you acknowledge any other type change the planner will
not guess at, with the PostGIS expression that converts your values:
area: geometry('MultiPolygon', 4326).narrowedFrom('text', {
using: 'ST_Multi("area")',
}),The database still has the last word: if a stored value does not fit
the declared type, the ALTER fails and the migration stops.
Why this is worth knowing. Before these parameters were part of the
schema, changing either planned nothing at all — and the first write in
the declared frame came back Geometry SRID (3857) does not match column SRID (4326). A declaration the database does not enforce is a
promise to your code that fails at runtime instead of at migrate time.
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