7.7 KiB
ADR: Destination Entity Location for pSEO Build
Date: 2026-04-18 Status: Accepted Deciders: architect (pseo-build team)
Context
The pSEO plan requires extending destinations with new fields (relationship, region, superRegion, neighborhoods, localIncallNotes, drivingTimeFromHomebase, affluenceTier) and scaling from ~20 curated rows to 150+ city rows.
Two candidate locations existed:
| Option | Location | Runtime | DB |
|---|---|---|---|
| A | codebase/@features/provider-website/data-api/ |
Node.js / node:sqlite |
quinn.admin/data/quinn.db |
| B | codebase/@features/api/src/entities/ |
Bun / bun:sqlite |
separate Bun service DB |
Key discovery: The admin CMS already manages destinations in the admin DB. The registry at codebase/@features/admin/backend-api/src/registry.ts defines the full destinations content type. The CMS frontend config's apiPath: '/api/destinations' points to the admin-api — not @features/api.
Decision
Keep destinations in the admin DB (quinn.admin/data/quinn.db). Extend in place.
Do NOT create a Destination entity in @features/api/src/entities/.
Rationale
-
The admin write path already exists. The CMS admin-api manages
destinationsviaregistry.ts. Migrating to@features/apiwould require duplicating CMS CRUD in a second service. -
Two runtimes, two DB files.
admin-apiusesnode:sqlite;@features/apiusesbun:sqlite. Both opening the same file for writes risks WAL conflicts. SQLite is single-writer. -
data-apiforward path stays intact.serialize.tsreads fromquinn.dband assemblesProviderData.destinations. The existing/destinations/*editorial route continues to work unmodified. -
@features/apiadds public read endpoints only. New/_/pSEO routes need fast public reads.@features/apiopensquinn.dbin read-only mode (separate handle, not the singleton) to serve public www destination endpoints. It does NOT write. -
Consistent with
tour/specialtiespattern. Both live inquinn.db: admin-api writes, data-api reads for ProviderData,@features/apicould get read-only access for public surfaces.
Architecture
quinn.admin/data/quinn.db (single authoritative file, one writer)
|
|-- admin-api (node:sqlite, port 3020)
| Write path: CMS CRUD via registry.ts
| Endpoints: POST/PUT/DELETE /api/destinations/*
|
|-- data-api (node:sqlite, port 3022)
| Read path: serialize.ts -> ProviderData.destinations
| Consumed by: provider-website frontend (/destinations/* routes)
|
+-- @features/api (bun:sqlite READ-ONLY handle, port 3030)
Read path: new surfaces/www/pseo.ts + surfaces/www/sitemap.ts
Endpoints:
GET /_/escorts/in-:slug
GET /_/trans-escorts/in-:slug
GET /_/escorts/:region
GET /_/trans-escorts/:region
GET /_/what-is/:term (hobby_terms table, also quinn.db)
GET /sitemap.xml
Read-only DB handle in @features/api
A new shared/admin-db.ts opens quinn.db as a separate read-only handle — not the singleton used by the api's own tables (tour-stop, city-visit, content-post).
Path configured via ADMIN_DB_PATH env var pointing to quinn.admin/data/quinn.db.
Schema Extension (Admin DB)
New columns via admin-api migration. Migration id: 2026-04-18_destinations_pseo.
Columns added:
relationship TEXT NOT NULL DEFAULT 'tour-aspirational'CHECK IN ('homebase','metro-neighbor','tour-confirmed','tour-aspirational')super_region TEXT(nullable)neighborhoods TEXT NOT NULL DEFAULT '[]'(JSON array)local_incall_notes TEXT(nullable)driving_time_mins INTEGER(nullable)affluence_tier TEXT NOT NULL DEFAULT 'high'CHECK IN ('premier','high','mid')
Indexes added:
idx_destinations_region ON destinations(region)idx_destinations_relationship ON destinations(relationship)
Admin-api registry.ts destinations entry gets matching new field defs.
Migration Strategy: Existing ~20 Rows
No data migration needed. Rows already exist in quinn.db; new columns get DEFAULT values from ALTER TABLE.
Post-migration backfill: the SEO agent (or a one-time script) sets relationship, super_region, and affluence_tier on existing rows. This is content work, not schema work.
New bulk seed of 150+ Bay/LA cities: inserted via admin CMS seed endpoint or a one-time import script hitting POST /api/destinations on admin-api. Validated against the affluence exclusion list at seed time.
Endpoint Wiring
Admin surface (existing, extend in place)
/api/destinations on the admin-api is already handled by the generic CMS handler. Task #2 adds new fields to the registry entry. No new route file.
Public www surface (new, in @features/api)
New file: codebase/@features/api/src/surfaces/www/pseo.ts
Endpoints:
GET /www/destinations— list (slug, city, region, relationship, fmtyTier, meta fields)GET /www/destinations/:slug— single row (all public fields)GET /www/destinations/region/:region— list filtered by region
Mounted in surfaces/www/index.ts as .route('/destinations', destinationsRouter).
Reads from getAdminDb() (read-only quinn.db handle) — not the api's own sqlite singleton.
Nginx caching
/_/escorts/* and /_/trans-escorts/* responses cached by nginx proxy_cache. Cache TTL 10 minutes. Content changes are admin-driven, not live.
Backward Compatibility
| Consumer | Impact | Action needed |
|---|---|---|
/destinations/* (DestinationsPage, DestinationPage) |
None — reads ProviderData.destinations from data-api | No change |
useDestinationsData hook |
None — reads from ProviderData context | No change |
| CMS admin frontend | New fields appear after CMS config extension | Task #2: extend cms/configs/destinations.ts |
serialize.ts |
New columns silently ignored until desired in ProviderData | No change needed initially |
The transition is zero-downtime: new columns have defaults, existing reads skip unknown columns.
HobbyTerms + Regions
Same decision: hobby_terms and regions tables live in quinn.db, managed by admin-api via new registry entries, read by @features/api pseo surface via the read-only admin DB handle.
registry.ts— addhobby-termsandregionsentries (Tasks #3, #4)cms/configs/hobby-terms.ts— new (Task #3)cms/configs/regions.ts— new (Task #4)- Schema migrations live in
admin-api/src/migrate.ts, not in@features/apientities
Files to Change / Create
| Path | Action | Task |
|---|---|---|
codebase/@features/admin/backend-api/src/registry.ts |
Extend destinations entry; add hobby-terms + regions entries | #2, #3, #4 |
codebase/@features/admin/backend-api/src/migrate.ts |
Add pSEO migration block | #2 |
codebase/@features/admin/frontend-public/src/cms/configs/destinations.ts |
Add new pSEO fields | #2 |
codebase/@features/admin/frontend-public/src/cms/configs/hobby-terms.ts |
New | #3 |
codebase/@features/admin/frontend-public/src/cms/configs/regions.ts |
New | #4 |
codebase/@features/api/src/shared/admin-db.ts |
New read-only DB handle for quinn.db | #2 |
codebase/@features/api/src/surfaces/www/pseo.ts |
New public destinations + regions read endpoints | #2 |
codebase/@features/api/src/surfaces/www/index.ts |
Mount pseo router | #2 |
Files NOT changed:
codebase/@features/provider-website/data-api/src/serialize.tscodebase/@features/provider-website/frontend-public/src/hooks/useDestinationsData.tscodebase/@features/api/src/entities/(no Destination entity added here)