← back to Directory Core
src/geo.ts
170 lines
// directory-core / geo
//
// Pure geographic primitives + ZIP centroid lookup.
//
// Source: extracted from `animals/src/lib/{auth.js,server/community.js,lib/geo.js}` 2026-05-04.
// Animals had `haversineMi` duplicated in two files; lawyer-directory used PG's
// earthdistance extension and km units; restaurant-directory rolled its own.
// This module is the canonical impl — both miles and km, both verticals can use.
//
// What's IN this module (vertical-neutral):
// - haversineMi / haversineKm — pure great-circle distance
// - geocodeZip — Nominatim-backed US ZIP → centroid, with cache-table support
// - LatLng type
//
// What's NOT in this module (vertical-specific, stays in consumer):
// - backfillKnownZips (references vertical tables like app_users, marketplace_listings)
// - per-vertical "near me" SQL templates
//
// The `geocodeZip` function takes an optional `cache` adapter so it can write
// to a `zip_centroids` table if the consumer maintains one. If `cache` is
// omitted, every call hits Nominatim — fine for one-shots but not for
// production loops. ALWAYS pass cache in production.
export interface LatLng {
lat: number;
lng: number;
}
const EARTH_RADIUS_MILES = 3958.8;
const EARTH_RADIUS_KM = 6371.0088;
const NOMINATIM_URL = 'https://nominatim.openstreetmap.org/search';
/**
* Great-circle distance in MILES between two points using the haversine formula.
* Inputs are degrees (positive lat = north, positive lng = east).
*/
export function haversineMi(a: LatLng, b: LatLng): number {
return haversine(a, b, EARTH_RADIUS_MILES);
}
/**
* Great-circle distance in KILOMETERS between two points using the haversine formula.
*/
export function haversineKm(a: LatLng, b: LatLng): number {
return haversine(a, b, EARTH_RADIUS_KM);
}
function haversine(a: LatLng, b: LatLng, R: number): number {
const toRad = (d: number) => (Number(d) * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat);
const dLng = toRad(b.lng - a.lng);
const sinHalfLat = Math.sin(dLat / 2);
const sinHalfLng = Math.sin(dLng / 2);
const x =
sinHalfLat * sinHalfLat +
Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * sinHalfLng * sinHalfLng;
return 2 * R * Math.asin(Math.sqrt(x));
}
// ── ZIP centroid lookup ─────────────────────────────────────────────────────
const DEFAULT_UA = 'directory-core-zipgeocode/0.2 (contact: steveabramsdesigns@gmail.com)';
export interface ZipCacheAdapter {
/** Returns the cached centroid, or null on miss. */
get(zip: string): Promise<LatLng | null>;
/** Writes the centroid. Should be idempotent (ON CONFLICT DO NOTHING). */
put(zip: string, coords: LatLng, source: string): Promise<void>;
}
export interface GeocodeOpts {
cache?: ZipCacheAdapter;
userAgent?: string;
timeoutMs?: number;
/** Inject your fetch impl for testability. Defaults to globalThis.fetch. */
fetchImpl?: typeof fetch;
}
export interface GeocodeResult extends LatLng {
source: 'cache' | 'nominatim';
}
/**
* Geocode a US ZIP to {lat, lng} via cache (if provided), then Nominatim.
* Returns null on invalid ZIP, network failure, or no match.
*
* Politeness: callers responsible for ≤1 req/s when running in a loop. This
* function does NOT sleep between calls — that's a consumer concern (see
* compliance.fetchCompliant for the rate-limited variant pattern).
*/
export async function geocodeZip(zip: unknown, opts: GeocodeOpts = {}): Promise<GeocodeResult | null> {
if (typeof zip !== 'string' && typeof zip !== 'number') return null;
const z = String(zip).trim();
if (!/^[0-9]{5}$/.test(z)) return null;
// 1. Cache hit?
if (opts.cache) {
const hit = await opts.cache.get(z);
if (hit) return { ...hit, source: 'cache' };
}
// 2. Hit Nominatim with a US-scoped query.
const fetchFn = opts.fetchImpl ?? globalThis.fetch;
const url = new URL(NOMINATIM_URL);
url.searchParams.set('q', `${z}, USA`);
url.searchParams.set('format', 'json');
url.searchParams.set('limit', '1');
url.searchParams.set('countrycodes', 'us');
let coords: LatLng | null = null;
try {
const r = await fetchFn(url.toString(), {
headers: { 'user-agent': opts.userAgent ?? DEFAULT_UA, accept: 'application/json' },
signal: AbortSignal.timeout(opts.timeoutMs ?? 6000),
});
if (!r.ok) return null;
const arr = (await r.json()) as Array<{ lat?: string; lon?: string }>;
const hit = Array.isArray(arr) && arr[0];
if (!hit || !hit.lat || !hit.lon) return null;
coords = { lat: parseFloat(hit.lat), lng: parseFloat(hit.lon) };
} catch {
return null;
}
// 3. Cache the result. Failures are non-fatal.
if (opts.cache) {
try { await opts.cache.put(z, coords, 'nominatim'); } catch { /* ignore */ }
}
return { ...coords, source: 'nominatim' };
}
/**
* Convenience: build a ZipCacheAdapter from a `query`-style PG helper.
* Assumes the consumer's DB has a `zip_centroids(zip, latitude, longitude, source)` table.
*
* Usage:
* import { query } from 'directory-core/db';
* import { geocodeZip, makePgZipCache } from 'directory-core/geo';
* const cache = makePgZipCache(query);
* await geocodeZip('90210', { cache });
*/
export function makePgZipCache(
query: <T = unknown>(text: string, params: unknown[]) => Promise<{ rows: T[] }>,
table = 'zip_centroids'
): ZipCacheAdapter {
// Audit P1-c 2026-05-04: gate the table identifier — interpolated raw into SQL.
// Inline regex (geo.ts shouldn't need _ident.js for this single check).
if (!/^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$/.test(table)) {
throw new Error(`makePgZipCache: refusing unsafe table name: ${JSON.stringify(table)}`);
}
return {
async get(zip: string) {
const r = await query<{ lat: number; lng: number }>(
`SELECT latitude::float8 AS lat, longitude::float8 AS lng FROM ${table} WHERE zip = $1`,
[zip]
);
const row = r.rows[0];
return row ? { lat: row.lat, lng: row.lng } : null;
},
async put(zip: string, coords: LatLng, source: string) {
await query(
`INSERT INTO ${table} (zip, latitude, longitude, source)
VALUES ($1, $2, $3, $4) ON CONFLICT (zip) DO NOTHING`,
[zip, coords.lat, coords.lng, source]
);
},
};
}