@happyvertical/smrt-places
Hierarchical place management with geo-coordinates, address validation, and territory mapping.
Overview
smrt-places is a comprehensive location management system for the SMRT framework. It provides hierarchical organization of geographic locations with automatic geocoding integration, proximity-based searches, and support for both real-world places (with coordinates) and abstract places (virtual worlds, game zones, organizational units).
The module enables organic database growth through lookupOrCreate(), which automatically
queries geocoding services (OpenStreetMap or Google Maps) for missing locations. Places can be
organized in unlimited nesting depth (Country → Region → City → Building → Room), and each place
can store custom metadata for domain-specific attributes.
Installation
npm install @happyvertical/smrt-places
# or
pnpm add @happyvertical/smrt-placesThe module depends on @happyvertical/smrt-core for base classes and @happyvertical/geo for geocoding integration. It works seamlessly with smrt-tenancy for multi-tenant isolation.
Quick Start (5 Minutes)
Here's a minimal example showing how to create a place hierarchy, use geocoding, and search by proximity:
1. Initialize Collections
import { PlaceCollection, PlaceTypeCollection } from '@happyvertical/smrt-places';
const places = new PlaceCollection({ db: {...} });
const placeTypes = new PlaceTypeCollection({ db: {...} });
// Seed default place types (country, region, city, etc.)
await placeTypes.initializeDefaults();2. Create Place Hierarchy Manually
// Create country
const usa = await places.create({
name: 'United States',
typeId: (await placeTypes.getBySlug('country'))?.id,
countryCode: 'US'
});
// Create state/region
const california = await places.create({
name: 'California',
typeId: (await placeTypes.getBySlug('region'))?.id,
parentId: usa.id,
region: 'CA'
});
// Create city
const sanFrancisco = await places.create({
name: 'San Francisco',
typeId: (await placeTypes.getBySlug('city'))?.id,
parentId: california.id,
city: 'San Francisco',
region: 'CA',
country: 'United States'
});3. Use Organic Database Growth (lookupOrCreate)
// Forward geocoding: address → place with coordinates
const office = await places.lookupOrCreate(
'1234 Market Street, San Francisco, CA',
{
geoProvider: 'openstreetmap', // Free, no API key needed
typeSlug: 'address',
createIfNotFound: true
}
);
console.log('Address found:', office.name);
console.log('Coordinates:', office.latitude, office.longitude);
console.log('Full address:', office.streetNumber, office.streetName, office.city);
// Reverse geocoding: coordinates → place with address
const placeFromCoords = await places.lookupOrCreate(
{ lat: 37.7749, lng: -122.4194 },
{
geoProvider: 'openstreetmap',
typeSlug: 'address',
createIfNotFound: true
}
);
console.log('Place from coords:', placeFromCoords.name);4. Search by Proximity
// Find all places within 5km of coordinates
const nearbyPlaces = await places.searchByProximity(
37.7749,
-122.4194,
5 // radius in km
);
console.log('Found', nearbyPlaces.length, 'places nearby');
nearbyPlaces.forEach(place => {
console.log('-', place.name, '| Distance:', place.distance, 'km');
});Core Concepts
Place Model Structure
The Place model supports both physical locations (with coordinates) and abstract
places (virtual worlds, game zones). All geographic fields are optional:
class Place extends SmrtObject {
// Core fields
id: string
typeId: string // Reference to PlaceType
parentId: string | null // Self-referencing hierarchy
name: string
description: string
// Geographic fields (all optional)
latitude: number | null
longitude: number | null
streetNumber: string
streetName: string
city: string
region: string
country: string
postalCode: string
countryCode: string
timezone: string
// Metadata and tracking
externalId: string // For syncing with external systems
source: string // 'openstreetmap', 'google', 'manual'
metadata: Record<string, any> // JSON storage for custom attributes
// Methods
getGeoData(): GeoData
hasCoordinates(): boolean
getMetadata(): Record<string, any>
setMetadata(data: Record): void
updateMetadata(updates: Record): void
}Hierarchical Organization
Places form a self-referencing hierarchy through the parentId field. This enables
unlimited nesting depth for organizing locations:
- 🌍 United States → California → San Francisco → Market Street → Building 1234 → Floor 5 → Room 501
- 🎮 Game World → Eastern Kingdom → Forest of Doom → Ancient Temple → Treasure Chamber
- 🏢 Acme Corp → West Coast Office → Floor 3 → Engineering → Desk 42
// Get immediate parent
const parent = await place.getParent();
// Get all children
const children = await place.getChildren();
// Get all ancestors (root to parent)
const ancestors = await place.getAncestors();
// Get all descendants (children, grandchildren, etc.)
const descendants = await place.getDescendants();
// Get complete hierarchy
const hierarchy = await place.getHierarchy();
// Returns: { ancestors: Place[], current: Place, descendants: Place[] }PlaceType System
Places are categorized using the PlaceType model with slug-based identification.
Default types include:
- country
- region
- city
- address
- building
- room
- zone
- point_of_interest
// Get or create type (idempotent)
const buildingType = await placeTypes.getOrCreate('building', 'Building');
// Look up by slug
const cityType = await placeTypes.getBySlug('city');
// Create custom type
const restaurantType = await placeTypes.getOrCreate('restaurant', 'Restaurant');Geocoding Integration
The module integrates with @happyvertical/geo for automatic geocoding using
OpenStreetMap (free, no API key) or Google Maps (requires API key):
// Forward geocoding: address → coordinates + place data
const place = await places.lookupOrCreate(
'1600 Amphitheatre Parkway, Mountain View, CA',
{
geoProvider: 'google', // or 'openstreetmap'
typeSlug: 'address',
createIfNotFound: true
}
);
// Automatically populated: lat, lng, streetNumber, streetName, city, region, etc.
// Reverse geocoding: coordinates → address + place data
const place2 = await places.lookupOrCreate(
{ lat: 37.4220, lng: -122.0841 },
{
geoProvider: 'openstreetmap',
typeSlug: 'address',
createIfNotFound: true
}
);
// Automatically populated from geocoding serviceAbstract Places (No Coordinates)
Places don't require coordinates - perfect for virtual worlds, game zones, or organizational structures:
// Create game world hierarchy
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',
hasFlyingMount: false
}
});
const poi = await places.create({
name: 'Stormwind Keep',
typeId: (await placeTypes.getOrCreate('point_of_interest', 'POI')).id,
parentId: zone.id,
metadata: {
questGiver: 'King Varian Wrynn',
services: ['Bank', 'Auction House', 'Vendors']
}
});API Reference
Place Model Methods
// Geographic data
place.getGeoData(): GeoData // Get all geographic fields
place.hasCoordinates(): boolean // Check if lat/lng are set
// Metadata management
place.getMetadata(): Record<string, any> // Parse and return JSON metadata
place.setMetadata(data): void // Replace entire metadata
place.updateMetadata(updates): void // Merge updates into metadata
// Relationships
await place.getType(): PlaceType | null
await place.getParent(): Place | null
await place.getChildren(): Place[]
await place.getAncestors(): Place[] // Root-to-parent chain
await place.getDescendants(): Place[] // All children recursively
await place.getHierarchy(): PlaceHierarchyPlaceCollection Methods
// Organic database growth
await places.lookupOrCreate(query, options?): Promise<Place | null>
// query: address string OR { lat, lng }
// options: { geoProvider?, typeSlug?, parentId?, createIfNotFound? }
// Hierarchy traversal
await places.getChildren(parentId): Promise<Place[]>
await places.getRootPlaces(): Promise<Place[]>
await places.getHierarchy(placeId): Promise<PlaceHierarchy>
// Type-based queries
await places.getByType(typeSlug): Promise<Place[]>
// Proximity search
await places.searchByProximity(lat, lng, radiusKm): Promise<Place[]>
// Returns places sorted by distance with distance property addedPlaceTypeCollection Methods
// Idempotent type access
await placeTypes.getOrCreate(slug, name?): Promise<PlaceType>
// Lookup
await placeTypes.getBySlug(slug): Promise<PlaceType | null>
// Initialize defaults
await placeTypes.initializeDefaults(): Promise<PlaceType[]>
// Seeds: country, region, city, address, building, room, zone, point_of_interestUtility Functions
import {
validateCoordinates,
calculateDistance,
formatCoordinates,
parseCoordinates,
areCoordinatesNear,
generateDisplayName,
normalizeAddressComponents
} from '@happyvertical/smrt-places';
// Validation
const result = validateCoordinates(37.7749, -122.4194);
// Returns: { valid: true } or { valid: false, error: string }
// Distance calculation (Haversine formula)
const distanceKm = calculateDistance(
37.7749, -122.4194, // San Francisco
34.0522, -118.2437 // Los Angeles
);
console.log(distanceKm); // ~559 km
// Formatting
const formatted = formatCoordinates(37.7749, -122.4194, 4);
// "37.7749, -122.4194"
// Parsing
const coords = parseCoordinates("37.7749, -122.4194");
// { lat: 37.7749, lng: -122.4194 }
// Proximity check
const isNear = areCoordinatesNear(
37.7749, -122.4194,
37.7750, -122.4195,
0.1 // threshold in km
);
// true
// Display name generation
const display = generateDisplayName({
streetNumber: '1234',
streetName: 'Market Street',
city: 'San Francisco',
region: 'CA'
});
// "1234 Market Street, San Francisco, CA"Tutorials
Tutorial 1: Basic Place Creation and Hierarchy (10-15 min)
Build a location hierarchy from scratch:
- Initialize PlaceTypeCollection and seed default types
- Create country, region, city hierarchy manually
- Retrieve complete hierarchy using getHierarchy()
- List all places at each level
- Navigate parent-child relationships
Tutorial 2: Organic Database Growth with lookupOrCreate (15-20 min)
Automatically populate places using geocoding:
- Forward geocoding (address string → place with coordinates)
- Reverse geocoding (coordinates → place with address)
- Compare OpenStreetMap vs Google Maps providers
- Control creation with createIfNotFound flag
- Store and retrieve metadata from geocoding results
Tutorial 3: Proximity Search and Location Queries (15-20 min)
Find nearby places and calculate distances:
- Populate database with places at real coordinates
- Search places within 10km radius
- Calculate distances between specific places
- Filter results by place type
- Sort by relevance/distance
Tutorial 4: Abstract Places and Virtual Worlds (15-20 min)
Build location systems without real-world coordinates:
- Create custom place types for game zones (zone, area, sector)
- Build hierarchy without geocoding
- Store game-specific metadata
- Query abstract place hierarchies
- Mix physical and abstract places in same system
Real-World Examples
Example 1: Restaurant Locator Application
Build a restaurant finder with automatic geocoding:
// Create restaurant type
const restaurantType = await placeTypes.getOrCreate('restaurant', 'Restaurant');
// Add restaurants using addresses (organic growth)
const restaurant1 = await places.lookupOrCreate(
'123 Main Street, San Francisco, CA',
{
geoProvider: 'openstreetmap',
typeSlug: 'restaurant',
createIfNotFound: true
}
);
// Store restaurant-specific metadata
restaurant1.updateMetadata({
cuisineType: 'Italian',
priceRange: '$$',
rating: 4.5,
hours: {
monday: '11:00-22:00',
tuesday: '11:00-22:00',
// ...
},
reservations: true,
delivery: false
});
await restaurant1.save();
// Find restaurants near user
const userLat = 37.7749;
const userLng = -122.4194;
const nearbyRestaurants = await places.searchByProximity(userLat, userLng, 5);
// Filter by type and metadata
const italianRestaurants = nearbyRestaurants.filter(place => {
const metadata = place.getMetadata();
return metadata.cuisineType === 'Italian';
});
console.log('Found', italianRestaurants.length, 'Italian restaurants within 5km');Example 2: Event Management System
Manage venues with hierarchical room organization:
// Create venue hierarchy
const city = await places.create({
name: 'Chicago',
typeId: (await placeTypes.getBySlug('city'))?.id,
latitude: 41.8781,
longitude: -87.6298
});
const building = await places.create({
name: 'Convention Center',
typeId: (await placeTypes.getBySlug('building'))?.id,
parentId: city.id,
streetName: 'Michigan Avenue',
streetNumber: '2301',
latitude: 41.8781,
longitude: -87.6298
});
// Create floors
const floor1 = await places.create({
name: 'Floor 1',
typeId: (await placeTypes.getOrCreate('floor', 'Floor')).id,
parentId: building.id
});
const floor2 = await places.create({
name: 'Floor 2',
typeId: (await placeTypes.getOrCreate('floor', 'Floor')).id,
parentId: building.id
});
// Create rooms with capacity metadata
const rooms = [
{ name: 'Grand Ballroom', floor: floor1, capacity: 500 },
{ name: 'Conference Room A', floor: floor1, capacity: 50 },
{ name: 'Conference Room B', floor: floor2, capacity: 30 }
];
for (const roomData of rooms) {
const room = await places.create({
name: roomData.name,
typeId: (await placeTypes.getBySlug('room'))?.id,
parentId: roomData.floor.id,
metadata: {
capacity: roomData.capacity,
equipment: ['Projector', 'Sound system', 'WiFi'],
accessible: true
}
});
}
// Query all rooms in building
const allRooms = await building.getDescendants();
const roomsOnly = allRooms.filter(async place => {
const type = await place.getType();
return type?.slug === 'room';
});
// Find available spaces near user location
const nearbyVenues = await places.searchByProximity(41.8781, -87.6298, 10);
console.log('Found', nearbyVenues.length, 'venues within 10km');Example 3: Game World Builder
Create abstract place hierarchies for virtual worlds:
// Create game world (no coordinates needed)
const world = await places.create({
name: 'Eldoria',
typeId: (await placeTypes.getOrCreate('world', 'World')).id,
description: 'Fantasy MMORPG world',
metadata: {
maxPlayers: 10000,
serverRegion: 'US-West',
version: '2.1.0'
}
});
// Create regions
const easternKingdom = await places.create({
name: 'Eastern Kingdom',
typeId: (await placeTypes.getOrCreate('region', 'Region')).id,
parentId: world.id,
metadata: {
faction: 'Alliance',
recommendedLevel: '1-40',
climate: 'Temperate'
}
});
// Create zones
const elwynnForest = await places.create({
name: 'Elwynn Forest',
typeId: (await placeTypes.getOrCreate('zone', 'Zone')).id,
parentId: easternKingdom.id,
metadata: {
levelRange: '1-10',
enemyTypes: ['Wolf', 'Bear', 'Defias Bandit'],
resources: ['Wood', 'Copper Ore'],
questHubs: ['Goldshire', 'Northshire Abbey']
}
});
// Create points of interest
const goldshire = await places.create({
name: 'Goldshire',
typeId: (await placeTypes.getOrCreate('point_of_interest', 'POI')).id,
parentId: elwynnForest.id,
metadata: {
type: 'town',
npcs: ['Innkeeper', 'Blacksmith', 'Quest Giver'],
services: ['Rest area', 'Vendors', 'Flight path'],
safeZone: true
}
});
// Query game hierarchy
const worldHierarchy = await world.getHierarchy();
console.log('World structure:');
console.log('- World:', worldHierarchy.current.name);
console.log('- Regions:', worldHierarchy.descendants.filter(p => p.typeId === easternKingdom.typeId).length);
console.log('- Total locations:', worldHierarchy.descendants.length);Example 4: Real Estate Platform
Organize properties with geocoding and search:
// Property hierarchy: Country → City → Building → Unit
const propertyType = await placeTypes.getOrCreate('property', 'Property');
// Create building with geocoding
const building = await places.lookupOrCreate(
'555 California Street, San Francisco, CA',
{
geoProvider: 'google',
typeSlug: 'building',
createIfNotFound: true
}
);
building.updateMetadata({
buildingName: 'Sunset Tower',
yearBuilt: 2010,
floors: 20,
parking: true,
amenities: ['Pool', 'Gym', 'Concierge']
});
await building.save();
// Create units within building
for (let floor = 1; floor <= 20; floor++) {
for (const unit of ['A', 'B', 'C', 'D']) {
const unitNumber = floor * 100 + unit.charCodeAt(0) - 64;
const propertyUnit = await places.create({
name: 'Unit ' + unitNumber,
typeId: propertyType.id,
parentId: building.id,
latitude: building.latitude,
longitude: building.longitude,
metadata: {
floor: floor,
bedrooms: floor < 10 ? 1 : 2,
bathrooms: floor < 10 ? 1 : 2,
sqft: floor < 10 ? 800 : 1200,
price: (floor < 10 ? 2500 : 3500) + (floor * 50),
available: Math.random() > 0.7,
viewType: floor > 15 ? 'City view' : 'Street view'
}
});
}
}
// Search for properties near location
const searchLat = 37.7749;
const searchLng = -122.4194;
const nearbyProperties = await places.searchByProximity(searchLat, searchLng, 2);
// Filter by availability and price
const availableUnits = nearbyProperties.filter(place => {
const metadata = place.getMetadata();
return metadata.available && metadata.price <= 3000;
});
console.log('Found', availableUnits.length, 'available units under $3000 within 2km');Example 5: Supply Chain Management
Track warehouse locations and zones:
// Warehouse hierarchy: Country → Distribution Center → Warehouse → Zone → Shelf
const dcType = await placeTypes.getOrCreate('distribution_center', 'Distribution Center');
const warehouseType = await placeTypes.getOrCreate('warehouse', 'Warehouse');
const zoneType = await placeTypes.getOrCreate('zone', 'Zone');
const shelfType = await placeTypes.getOrCreate('shelf', 'Shelf');
// Create distribution center
const dc = await places.create({
name: 'West Coast DC',
typeId: dcType.id,
latitude: 37.5483,
longitude: -121.9886,
city: 'Tracy',
region: 'CA',
metadata: {
operatingHours: '24/7',
maxCapacity: 1000000, // sqft
manager: 'John Doe'
}
});
// Create warehouse within DC
const warehouse = await places.create({
name: 'Warehouse A',
typeId: warehouseType.id,
parentId: dc.id,
latitude: dc.latitude,
longitude: dc.longitude,
metadata: {
temperature: 'ambient',
securityLevel: 'high'
}
});
// Create zones within warehouse
for (const zoneName of ['Receiving', 'Picking', 'Packing', 'Shipping']) {
const zone = await places.create({
name: zoneName + ' Zone',
typeId: zoneType.id,
parentId: warehouse.id,
metadata: {
capacity: 10000,
currentOccupancy: Math.floor(Math.random() * 10000)
}
});
// Create shelves within zone
for (let aisle = 1; aisle <= 10; aisle++) {
for (let position = 1; position <= 20; position++) {
const shelf = await places.create({
name: 'Shelf ' + zoneName[0] + '-' + aisle + '-' + position,
typeId: shelfType.id,
parentId: zone.id,
metadata: {
aisle: aisle,
position: position,
height: Math.floor(Math.random() * 3) + 1, // 1-3 levels
occupied: Math.random() > 0.3
}
});
}
}
}
// Query all shelves in warehouse
const allDescendants = await warehouse.getDescendants();
const shelves = allDescendants.filter(async place => {
const type = await place.getType();
return type?.slug === 'shelf';
});
console.log('Total shelves in warehouse:', shelves.length);
// Calculate distances for route optimization
const warehouseA = await places.findOne({ name: 'Warehouse A' });
const warehouseB = await places.findOne({ name: 'Warehouse B' });
const distance = calculateDistance(
warehouseA.latitude, warehouseA.longitude,
warehouseB.latitude, warehouseB.longitude
);
console.log('Distance between warehouses:', distance, 'km');Integration Patterns
With smrt-core
Place and PlaceType extend SmrtObject with full SMRT framework integration:
@smrtdecorator auto-generates REST APIs, CLI commands, and MCP tools- Automatic database schema generation from TypeScript types
- Built-in validation, lifecycle hooks (beforeSave, afterSave)
- AI-powered methods:
do()andis()
With @happyvertical/geo
Seamless geocoding integration for organic database growth:
- PlaceCollection internally uses geo adapter for lookupOrCreate()
- OpenStreetMap provider (free, no API key)
- Google Maps provider (requires GOOGLE_MAPS_API_KEY env var)
- Automatic address component extraction and population
With smrt-tenancy
Multi-tenant place isolation:
- Each tenant has separate place hierarchies
- Query scoping via tenant context in collection options
- Shared schema, separate data per tenant
- Permission checks control which users access which places
With smrt-users
User location tracking and proximity features:
- User profiles store
placeIdforeign key - Track user location history (visits to places)
- Proximity-based user discovery (find users nearby)
- Location-based permissions/roles
With smrt-content
Location-specific content:
- Content references places via
placeIdforeign key - Blog posts/articles about specific locations
- Location guides and documentation
- Metadata synchronization between content and places
Best Practices
Creating Hierarchies
- Start with root places (countries) and build downward
- Use consistent naming conventions across hierarchy levels
- Assign type slugs early for better querying and filtering
- Store external IDs for syncing with other systems
Geocoding
- Use OpenStreetMap by default (free, no API key needed)
- Switch to Google Maps for better accuracy on edge cases
- Set
createIfNotFound: falsefor lookup-only queries - Validate coordinates with
validateCoordinates()before storing - Check
sourcefield to track which provider created the place
Metadata Usage
- Store domain-specific attributes in JSON metadata field
- Use consistent schema across place types for easier querying
- Don't store sensitive data in metadata (no encryption)
- Validate metadata structure before
updateMetadata() - Consider extracting frequently-queried metadata to dedicated columns
Performance
- Index frequently-queried fields (parentId, typeId, city, region)
- Use proximity search carefully on large datasets (pre-filter by type)
- Cache hierarchy results when possible (e.g., getHierarchy())
- Batch load relationships to avoid N+1 queries
- Consider pagination for large result sets
Abstract Places
- Skip all geographic fields for virtual/abstract places
- Use descriptive type slugs ('zone', 'area', 'sector')
- Store domain-specific data in metadata field
- Hierarchies can be arbitrarily deep (no practical limit)
- Mix physical and abstract places in same system
Common Mistakes to Avoid
- Don't create circular references (Child → Parent → Grandparent → Child)
- Don't store massive objects in metadata (keep under 10KB)
- Don't use geocoding for every query (cache lookups)
- Don't forget to set
typeId(required field) - Don't mix coordinate systems (always use decimal degrees)
Common Issues and Troubleshooting
Error: "Place not found" on lookupOrCreate()
Cause: Geocoding service can't find the address
Solution: Check internet access, verify address format, try different provider, or use reverse geocoding with coordinates instead.
// Try different format
const place1 = await places.lookupOrCreate('San Francisco, CA, USA', {...});
// Or use coordinates
const place2 = await places.lookupOrCreate(
{ lat: 37.7749, lng: -122.4194 },
{...}
);Error: "Invalid coordinates"
Cause: Latitude or longitude out of valid range
Solution: Validate with validateCoordinates(). Latitude: -90 to 90,
Longitude: -180 to 180. Watch for swapped values.
const result = validateCoordinates(lat, lng);
if (!result.valid) {
console.error('Invalid coordinates:', result.error);
return;
}Issue: Slow hierarchy traversal
Cause: Multiple database queries for ancestors/descendants
Solution: Use getHierarchy() once instead of separate calls.
Create database indexes on parentId. Cache results for frequently-accessed hierarchies.
// Efficient: single call
const hierarchy = await place.getHierarchy();
const ancestors = hierarchy.ancestors;
const descendants = hierarchy.descendants;
// Less efficient: multiple calls
const ancestors = await place.getAncestors();
const descendants = await place.getDescendants();Issue: Geocoding provider inconsistencies
Cause: Different providers return different address formats
Solution: Store source field to track provider. Use normalizeAddressComponents() to standardize formats. Consider fallback provider.
const place = await places.lookupOrCreate(address, {
geoProvider: 'google',
createIfNotFound: true
});
console.log('Created by:', place.source); // 'google'
// Try fallback if needed
if (!place) {
place = await places.lookupOrCreate(address, {
geoProvider: 'openstreetmap',
createIfNotFound: true
});
}Issue: Abstract place queries returning empty
Cause: Incorrect parentId or typeId
Solution: Verify parentId is set correctly (null or empty = root level).
Check typeId matches expected type. Use getHierarchy() to inspect structure.
// Check place structure
const hierarchy = await place.getHierarchy();
console.log('Parent:', hierarchy.current.parentId);
console.log('Type:', await hierarchy.current.getType());
console.log('Children:', hierarchy.descendants.length);Issue: Circular reference errors
Cause: Attempting to create Parent → Child → Parent loop
Solution: Database foreign key constraints prevent this. Always verify parent exists before setting parentId. Use transactions when restructuring hierarchies.
// Safe: check parent exists
const parent = await places.findById(parentId);
if (!parent) {
throw new Error('Parent not found');
}
const child = await places.create({
name: 'Child',
parentId: parent.id,
typeId: typeId
});