Geospatial
Store lat/lng points with v.geoPoint, index them with .geoIndex, and answer "places near me" and bounding-box reads with withGeoIndex.
Last updated:
Lunora stores geographic points as a first-class column and answers proximity
(near) and bounding-box (within) reads over them. Declare a v.geoPoint()
column, add a .geoIndex(), and read through it with withGeoIndex — the
runtime keeps a geohash companion so the query is a geohash-prefix range scan
refined and sorted by Haversine distance, not a full-table scan.
Declaring a point column and a geo index
v.geoPoint() validates a { lat, lng } object in WGS84 decimal degrees
(lat ∈ [-90, 90], lng ∈ [-180, 180]). .geoIndex(name, { field }) names the
geoPoint column it indexes; precision tunes the geohash length (default 9,
~4.8 m cells).
// lunora/schema.ts
import { defineSchema, defineTable, v } from "lunorash/server";
export default defineSchema({
places: defineTable({
name: v.string(),
category: v.string(),
location: v.geoPoint(),
}).geoIndex("by_location", { field: "location" }),
});"Places near me" — near(point, radiusMeters)
q.withIndex() has a geo counterpart, q.withGeoIndex(name, q => q.near(point, radius)).
It scans the geohash cells covering the circle, then refines by true Haversine
distance and orders the rows nearest-first (ties newest-first). Compose it
with any terminal — here .take(20) returns the 20 closest places, applied
after the distance refine.
import { query, v } from "@/lunora/_generated/server";
export const nearMe = query.input({ lat: v.number(), lng: v.number(), radiusMeters: v.number() }).query(async ({ ctx, args: { lat, lng, radiusMeters } }) => {
return ctx.db
.query("places")
.withGeoIndex("by_location", (q) => q.near({ lat, lng }, radiusMeters))
.take(20);
});Bounding box — within(box)
within takes a rectangle by its north-east and south-west corners and returns
every point inside it (inclusive), in creation-time order. Use it for
map-viewport queries — pass the visible bounds and get back the pins to draw.
import { query, v } from "@/lunora/_generated/server";
const corner = v.object({ lat: v.number(), lng: v.number() });
export const inViewport = query.input({ ne: corner, sw: corner }).query(async ({ ctx, args: { ne, sw } }) => {
return ctx.db
.query("places")
.withGeoIndex("by_location", (q) => q.within({ ne, sw }))
.collect();
});.near() and .within() are mutually exclusive on a single read — call one or
the other, not both.
Single-shard scope
A geo query resolves within one shard. On the default single-DO topology
that is the whole table, so nothing extra is required. If you .shardBy() a
table, each withGeoIndex read ranks points inside the resolved shard only —
cross-shard geo ranking is a deliberate non-goal in this
release, so shard geospatial tables by a key you also query within (e.g. a city
or region) rather than expecting a global nearest-across-all-shards result.
How the Advisors catch geo mistakes
The Advisors lint your schema and discovered queries:
a .geoIndex() that is declared but never read through, and a near/within
read against a table with no geo index, are both surfaced in the Studio.