@happyvertical/smrt-places

Hierarchical geographic database with organic growth via geocoding, Haversine proximity search, and dedicated place-owned asset joins.

v0.29.34GeographyHierarchy

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

bash
npm install @happyvertical/smrt-places
# or
pnpm add @happyvertical/smrt-places

Depends 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

typescript
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

typescript
// 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

typescript
// 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

typescript
// Returns tenant places PLUS global (tenantId=null) places
const visible = await places.findWithGlobals(currentTenantId);

Place Model

typescript
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.

typescript
// 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

MethodPurpose
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.

typescript
// 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.

typescript
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

typescript
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 km

Abstract Places

Places don't require coordinates — perfect for virtual worlds, game zones, or organizational structures. All geo fields are nullable.

typescript
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 — use findWithGlobals(tenantId) for tenant + global places
  • Use PlaceAsset (place_assets) for owned assets; reserve AssetAssociation for generic/provenance links

Related Modules