@lovett/geo
v0.4.2
Published
Typed client for the geocoder engine (geocode.edwinlovett.com) — the family's geometry oracle. Types and runtime shapes are generated from the engine's own OpenAPI spec, so a new endpoint reaches consumers by bumping this package, not by hand-writing a fe
Readme
@lovett/geo
The typed client for the geocoder engine — the family's geometry oracle at
geocode.edwinlovett.com.
Its types are generated from the engine's own openapi.json — all of it.
There are no hand-written request/response types in this package. That is the
whole point: an endpoint the engine gains reaches consumers by bumping this
package, not by someone hand-writing another fetch and getting the shape
subtly wrong.
Zero runtime dependencies — fetch, URL, AbortController only. Runs unchanged
in Cloudflare Workers, browsers, and Node ≥ 18.
pnpm add @lovett/geoUse it
import { createGeoClient, isMiss, isGeocodeHit } from '@lovett/geo'
const geo = createGeoClient() // defaults to geocode.edwinlovett.com
// Geo toolkit
const fit = await geo.geo.fitRadius({ zips: ['75034', '75035', '75024'] })
const reach = await geo.geo.reach({ zips: fit.zips }) // aggregate totals, one call
const ring = await geo.geo.ring({ center: { zip: '78701' }, inner_miles: 2, outer_miles: 8 })
// ZIP reference
const page = await geo.zips.search({ query: 'Frisco, TX' })
const near = await geo.zips.neighbors('75034')
// Geocoding
const hit = await geo.geocode('3015 W Kennedy Blvd, Tampa, FL')
if (isGeocodeHit(hit)) console.log(hit.lat, hit.lng)
// Batch geocode 23k rows: submit, poll, collect — one call
const out = await geo.batchToCompletion(addresses, {
onProgress: (s) => console.log(`${s.percentage}%`),
})
out.results // one JobRow per address
// Which provider is right? Ask all of them.
const audit = await geo.geocodeAll('3015 W Kennedy Blvd, Tampa, FL')Demographics without paying for demographics
The engine deliberately keeps ACS out of the geometry responses. Population is opt-in, in three shapes, and picking the right one is the difference between one request and four hundred:
// Aggregate — the reach counter. One call, any size selection.
await geo.geo.reach({ zips }) // → { population, households, unit_count }
// One point — the hover tooltip. Full ACS profile.
await geo.geo.demographics({ zip: '78701' }) // → median income, age, tenure, …
// Many ZIPs — the choropleth / table column. One call, and you pick the columns.
await geo.geo.demographicsBatch({ zips, fields: ['population'] })
// → results: [{ zip: '78701', population: 10659 }, …] ← nothing you didn't ask forA miss is a value, not an exception
The engine answers a lookup miss with a body, not a throw — and this client keeps
it that way. Branch, don't try:
const zip = await geo.zips.get('99999')
if (isMiss(zip)) return null // zip.error is a string here
zip.zipcode?.city // safe
const hit = await geo.geocode(row.address) // one bad row in 23k is routine
if (!isGeocodeHit(hit)) markForReview(row) // a miss is a 404, folded to a valueReal failures still throw a GeoApiError — branch on .code
(INVALID_PARAMS · OUT_OF_RANGE · NOT_FOUND · INTERNAL · BACKEND_UNAVAILABLE),
not on .status. The enum is append-only, so an unknown code is passed through
rather than rejected. Timeouts throw GeoTimeoutError.
Options
createGeoClient({
baseUrl: 'http://localhost:8080', // point at a local `cargo run`
timeoutMs: 30_000, // default 15s; 0 disables
headers: { authorization: '…' }, // for when the engine takes a service token
fetch: myFetch, // inject for tests / retries / tracing
})Every call takes the same knobs per-call: { timeoutMs, signal, headers }.
Call geo.warmup() fire-and-forget on app load — the engine lazily builds its
indexes, and this turns a ~150 ms first-request penalty into 0.
Picking up an engine change
The engine is the source of truth. This package is a projection of it.
pnpm sync # pull the live openapi.json + regenerate src/generated/schema.ts
pnpm verify # build + typecheck + test
# bump the version, publish, and consumers adopt it when they choose topnpm sync fetches from production. Use pnpm gen to regenerate from the
checked-in openapi.json instead (offline, reproducible).
A new endpoint is usable the moment you regenerate — you do not have to wait for a facade method, because the transport is generic over the spec:
await geo.http.post('/api/v1/geo/brand-new-thing', { … }) // fully typedThe methods on geo.geo.* / geo.zips.* / geo.boundaries.* buy naming and
grouping, nothing more. Their parameter and return types are derived from the
spec by path, so neither a shape change nor a schema rename in the generator can
silently drift them.
Consumers pin a version on purpose. A breaking engine change cannot quietly break eleven engines at once — each adopts it when it bumps.
The engine's spec covers 100% of its API
Every JSON route the engine serves is in openapi.json, so every type here is
generated. The engine's own drift check (scripts/check-endpoints.mjs) fails the
build if a route is added without documenting it, and its EXCLUDES list is down
to three entries — the HTML root, the Scalar page, and the spec document itself,
none of which serve JSON.
This was not free. Geocoding (/api/geocode, /api/batch, /api/job/*) used to
be undocumented, which forced this package to carry a hand-typed legacy.ts —
and that file was already wrong about two things before it was a day old (a
geocode miss is a 404, not the 200 the docs implied; and the non-v1 routes
answer with {success,error}, not the {code,message} envelope). Both were
caught by the live suite, and both are now encoded in the spec. legacy.ts is
deleted.
Two error shapes, and the spec now distinguishes them:
| Routes | Body | Branch on |
|---|---|---|
| /api/v1/* | ApiError — {code, message, details?} | err.code (append-only enum) |
| everything else | LegacyError — {success:false, error} | err.code too — the client normalizes it |
GeoApiError papers over the difference: it derives a code from the status when
the body has none, so switch (err.code) works against either shape.
Testing
pnpm test # unit — mocked transport, no network
pnpm test:live # smoke — hits the real engine (GEO_LIVE=1)The live suite is the one that earns its keep: it is what caught the 404-vs-200 geocode miss above. A generated type only proves the spec is consistent; only a live call proves the engine is.
