@happyvertical/smrt-places
Hierarchical geographic database with organic growth via geocoding, Haversine proximity search, and dedicated place-owned asset joins.
Overview
smrt-places is a comprehensive location management system for the SMRT
framework. Places form an unlimited-depth hierarchy (Country → Region → City → Building →
Room), support both real-world locations (with coordinates) and abstract places (virtual
worlds, game zones, organizational units), and grow organically through lookupOrCreate() + geocoding.
Tenancy
Both Place and PlaceType use @TenantScoped({ mode: 'optional' }). A place with tenantId = null is a global place visible to every tenant — useful for shared geo
reference data (countries, well-known landmarks). Tenant-specific places live alongside and
are retrieved together via findWithGlobals(tenantId).
Installation
npm install @happyvertical/smrt-places
# or
pnpm add @happyvertical/smrt-placesDepends on @happyvertical/smrt-core, @happyvertical/geo for
geocoding providers (OpenStreetMap, Google Maps), @happyvertical/sql for database
operations, and optionally @happyvertical/smrt-tenancy for multi-tenant scoping.
Quick Start
1. Initialize Collections
import { PlaceCollection, PlaceTypeCollection } from '@happyvertical/smrt-places';
const places = await PlaceCollection.create({ db });
const placeTypes = await PlaceTypeCollection.create({ db });
// Seed default place types (country, region, city, etc.)
await placeTypes.initializeDefaults();2. Organic Growth via lookupOrCreate
// DB first → geocode if not found → create
const office = await places.lookupOrCreate(
'1234 Market Street, San Francisco, CA',
{
geoProvider: 'openstreetmap', // free, no API key
typeSlug: 'address',
createIfNotFound: true
}
);
// Reverse geocoding: coordinates → place
// (query is always a string; pass coordinates via the coords option)
const placeFromCoords = await places.lookupOrCreate(
'',
{
coords: { lat: 37.7749, lng: -122.4194 },
geoProvider: 'openstreetmap',
typeSlug: 'address',
createIfNotFound: true,
}
);3. Proximity Search
// Haversine distance, returns Place[] sorted nearest-first
const nearby = await places.searchByProximity(37.7749, -122.4194, 5 /* km */);
nearby.forEach(p => console.log(p.name, p.latitude, p.longitude));4. Multi-Tenant Lookup
// Returns tenant places PLUS global (tenantId=null) places
const visible = await places.findWithGlobals(currentTenantId);Place Model
class Place extends SmrtHierarchical { // SmrtHierarchical provides parent/child traversal
typeId: string // FK to PlaceType
parentId: string | null // Self-referencing hierarchy (from SmrtHierarchical)
name: string
description: string
// Geo fields (all nullable -- abstract places have none)
latitude: number | null
longitude: number | null
streetNumber: string
streetName: string
city: string
region: string
country: string
postalCode: string
countryCode: string
timezone: string
// Provenance
externalId: string // for syncing with external systems
source: string // 'openstreetmap' | 'google' | 'manual'
metadata: Record<string, any>
// Hierarchy
async getParent(): Promise<Place | null>
async getChildren(): Promise<Place[]>
async getAncestors(): Promise<Place[]>
async getDescendants(): Promise<Place[]>
async getHierarchy(): Promise<PlaceHierarchy>
// Owned assets via place_assets
async getAssets(relationship?: string): Promise<Asset[]>
async addAsset(asset: Asset, relationship?: string, sortOrder?: number): Promise<void>
async removeAsset(assetId: string, relationship?: string): Promise<void>
hasCoordinates(): boolean
getGeoData(): GeoData
}Owned Assets via PlaceAsset (new)
place_assets is the canonical join for place-owned assets (photos, floor plans,
documents). Use the matching helpers on Place and PlaceCollection;
reserve generic AssetAssociation for cross-cutting / provenance links that don't belong
to a single owner.
// Owner-side helpers (addAsset takes a saved Asset, not just an id)
await place.addAsset(photoAsset, 'gallery', 0); // (asset, relationship?, sortOrder?)
const gallery = await place.getAssets('gallery'); // relationship filter is a string
await place.removeAsset(photoAsset.id, 'gallery');
// Collection-side helpers (operate on one place id at a time)
const sfGallery = await places.getAssets(sfId, 'gallery'); // Promise<Asset[]>
await places.addAsset(sfId, photoAsset, 'gallery', 1);Key Collection Methods
| Method | Purpose |
|---|---|
lookupOrCreate(query, opts) | DB first → geocode if not found → create. Organic growth pattern. Pass coordinates via opts.coords for reverse geocoding. |
searchByProximity(lat, lng, radiusKm?) | Haversine distance, returns Place[] sorted by proximity (default radius 10km) |
discoverNearby(lat, lng, radiusMeters, opts?) | Discover + persist POIs via the geo provider's findPoisNear; cached by
provider externalId |
resolveTrackPlaces(points, opts?) | Bucketed, throttled POI resolution along a GPS track; returns deduped places plus request / cache-hit / bucket counts |
findWithGlobals(tenantId) | Tenant + global (tenantId=null) places |
getRootPlaces(), getByType(slug) | Hierarchy + type queries |
getAssets(placeId, relationship?) / addAsset(placeId, asset, ...) / removeAsset(placeId, assetId, ...) | Owned-asset helpers backed by place_assets |
POI discovery: discoverNearby & resolveTrackPlaces
Beyond geocoding a known address, PlaceCollection can discover points of interest
around a coordinate. discoverNearby(lat, lng, radiusMeters) composes @happyvertical/geo's findPoisNear with the same ensureFromLocation path that lookupOrCreate uses, persisting each POI
as a Place. Caching is effectively automatic: the provider's own place id becomes
the Place row's externalId, so calling discoverNearby twice over the
same area on the same provider is a no-op after the first run (for providers that return stable
ids). It requires a provider that implements findPoisNear (default openstreetmap) and throws clearly otherwise.
// Discover and persist POIs within 250m.
const pois = await places.discoverNearby(37.7749, -122.4194, 250, {
geoProvider: 'openstreetmap', // default; 'google' also supported
keyword: 'cafe',
limit: 20,
});resolveTrackPlaces — POIs along a GPS track
resolveTrackPlaces(points, options) resolves POIs along a path (e.g. a video's
per-frame GPS track) without hammering the provider. Consecutive samples are usually within a
few meters, so it buckets points by greedy proximity (Haversine) into bucketMeters-wide
clusters, calls discoverNearby once per distinct bucket, and throttles requests by throttleMs to stay inside free-tier (Overpass / Nominatim) rate limits. Returned places are deduped across overlapping buckets by Place id.
const result = await places.resolveTrackPlaces(trackPoints, {
radiusMeters: 50, // search radius per bucket (default 50)
bucketMeters: 50, // points within this distance share one request (default 50)
throttleMs: 1100, // delay between provider calls (default 1100ms)
geoProvider: 'openstreetmap',
});
// TrackPlacesResult:
// {
// places: Place[], // deduped across buckets
// requestCount: number, // provider calls actually made
// cacheHitCount: number, // buckets where every POI already existed
// bucketCount: number, // distinct geographic buckets walked
// }Utility Functions
import {
validateCoordinates,
calculateDistance, // Haversine, returns km
formatCoordinates,
parseCoordinates,
areCoordinatesNear,
generateDisplayName,
normalizeAddressComponents
} from '@happyvertical/smrt-places';
const distanceKm = calculateDistance(37.7749, -122.4194, 34.0522, -118.2437);
// ~559 kmAbstract Places
Places don't require coordinates — perfect for virtual worlds, game zones, or organizational structures. All geo fields are nullable.
const world = await places.create({
name: 'Azeroth',
typeId: (await placeTypes.getOrCreate('world', 'World')).id,
description: 'Fantasy game world',
});
const zone = await places.create({
name: 'Stormwind City',
typeId: (await placeTypes.getOrCreate('zone', 'Zone')).id,
parentId: world.id,
metadata: {
levelRange: '1-60',
faction: 'Alliance',
},
});Gotchas
- Geo providers: Google Maps requires
GOOGLE_MAPS_API_KEY; OpenStreetMap is default and free (rate-limited) - Abstract places allowed: lat/lng are nullable — check
hasCoordinates()before using them - Coordinate tolerance varies by latitude: 0.0001° ≈ 11m at the equator, less at the poles
- Optional tenancy with nullable
tenantId— usefindWithGlobals(tenantId)for tenant + global places - Use
PlaceAsset(place_assets) for owned assets; reserveAssetAssociationfor generic/provenance links