trails/openspec/changes/poi-index/design.md
Ullrich Schäfer 5dd4968626 docs+openspec: prior-art research (Organic Maps, Endurain, wanderer) and 15 proposals
Add docs/inspirations.md as the durable record of the 2026-07-05/06
prior-art research — per-project learnings with source paths, canonical
credit lines, and the changes each spawned — and extend the
acknowledgment lists in philosophy.md/architecture.md (Organic Maps,
Endurain, wanderer).

New OpenSpec changes (proposal/design/specs/tasks each):
- Organic Maps: elevation-profile-hardening, gpx-parser-robustness,
  hiking-time-estimate, poi-index, hiking-foot-profile
- Endurain: account-export, activity-duplicate-review,
  fit-parsing-hardening, activity-locations, self-hosting-guide,
  activity-privacy-controls
- wanderer: federation-hardening, link-share-tokens
- credits-page (user-visible acknowledgments)

Updated in-flight changes with wanderer prior-art sections:
route-federation, route-discovery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 07:23:00 +02:00

8.1 KiB

Context

POI queries flow: browser (lib/overpass.ts, bbox quantized to a 0.01° grid) → /api/overpass proxy (same-origin + session checks, per-IP rate limit, response cache with coalescing, Grafana metrics) → overpass.private.coffee. All nine categories (packages/map-core/src/poi.ts) are unions of nwr["key"="value"] selectors with out center qt 100 — tag equality in a bbox, capped at 100 results, ways/relations reduced to center points. Nearby-POI lookup and snap-to-POI reuse the same path with a small bbox.

Two prior artifacts inform this change: the parked docs/ideas/self-host-overpass/ plan (full Overpass instance on the maintainer's dedicated box; heavy: regional DB, diff replication, ~23 GB RAM ceiling limits it to a DACH extract) and our Organic Maps review (2026-07-05), whose generator pipeline demonstrates the lighter pattern adopted here: batch-filter OSM data offline into a compact owned index, serve reads from that.

Relevant infra already in place: the BRouter host has 1.8 TB free disk, a Caddy sidecar, and a vSwitch link to the flagship (10.0.0.2); the flagship runs PostGIS and the planner already has a DB connection (planner.* schema, sessions).

Goals / Non-Goals

Goals:

  • Zero third-party dependency for POI data; viewport coordinates never leave trails.cool infrastructure.
  • Planet-wide coverage (the current upstream is planet-wide; a regional index would be a regression).
  • Identical user-facing behavior: same categories, marker data, popups, caps, and degradation UI.
  • Boring operations: a monthly batch job with an atomic swap and a freshness metric — no replication daemon.

Non-Goals:

  • Overpass QL compatibility (nothing uses it; the parked self-host-Overpass plan covers that path if ever needed).
  • POI name search, additional categories, journal consumption, or OSM editing — all possible later on top of the same table.
  • Minute-fresh data. POIs of these kinds churn slowly; monthly is enough (Organic Maps ships roughly monthly map data too).

Decisions

1. Data model: one flat planner.pois table, centroid-only

planner.pois: osm_type (n/w/r), osm_id, category, name (nullable), geom (geometry(Point, 4326) — centroid for ways/relations, matching today's out center), tags (jsonb, the element's full tag set for popups: opening hours, website, OSM link), imported_at. Primary key (osm_type, osm_id, category) — an element matching two categories appears once per category, which is how the client already treats results. Indexes: GiST on geom, btree on category. Serving query: WHERE category = ANY($cats) AND geom && ST_MakeEnvelope($bbox) with the existing 100-result cap.

Alternative: normalized category join table — rejected; the categories are a small fixed set and the flat table keeps the import a single COPY.

2. Category selectors move to structured data in map-core

poiCategories entries change from Overpass QL strings to selectors: Array<{key, value}>. Three consumers derive from this one definition: the pipeline's osmium filter expression, the importer's category classification, and (no longer) any client-side query building. This keeps "what is a shelter" defined in exactly one place — the same property the QL strings had, preserved across the migration.

3. Pipeline topology: filter where the disk is, load where the database is

  • BRouter host (monthly systemd timer): download planet PBF (~80 GB, kept only transiently), osmium tag-filter to our selectors (output: a few hundred MB), osmium export with centroid geometry to a gzipped NDJSON artifact {osm_type, osm_id, category[], name, lat, lon, tags}, publish it at a vSwitch-only path via the existing Caddy sidecar alongside a checksum + timestamp manifest.
  • Flagship (systemd timer, offset a day): fetch manifest + artifact over the vSwitch, COPY into planner.pois_staging, build indexes, then swap inside one transaction (ALTER TABLE ... RENAME).
  • Sanity guard: the importer aborts (no swap, loud log + metric) if the staging row count is < 70% of the live table's — a truncated download or broken filter can never blank the map. First import bootstraps with the guard disabled via flag.

Alternatives: streaming the planet through CI — GitHub runners lack the disk; importing directly from the BRouter host into flagship Postgres — rejected to avoid exposing Postgres on the vSwitch; a pg-boss job on the flagship — rejected because the heavy half (planet download/filter) can't run there anyway, and two small systemd timers are simpler than splitting one job across pg-boss and cron.

4. Serving route /api/pois, client keeps its shape

New apps/planner/app/routes/api.pois.ts (GET, bbox + categories params): same-origin + session checks, 120/IP/min rate limit (unchanged number — it now protects our DB), result cap 100, short Cache-Control so repeated identical viewport requests can be browser-cached. lib/overpass.ts is renamed/rewritten as a thin client keeping quantizeBbox (still valuable for client-cache hits), the Poi type, and the error mapping — so use-pois, use-nearby-pois, poi-cache, snap-to-POI, markers, and popups need no changes. The server-side response cache + coalescing from the proxy are dropped: a GiST-indexed point lookup is single-digit milliseconds and no upstream needs protecting.

5. Hard cutover, no dual-source period

/api/overpass and its private.coffee upstream are deleted in the same PR that lands /api/pois. Rollback for code problems is a git revert; the plausible non-code failure (import breaks later) degrades to stale POIs — objectively better than today's failure mode (no POIs during upstream flaps) — and is caught by the freshness metric. Keeping both paths alive would double the test surface for no benefit given the guard + metric.

6. Observability

New metrics replacing overpass_upstream_*: poi_index_rows{category}, poi_index_age_seconds (from the manifest timestamp), poi_api_requests_total{status}, import job success/failure. Grafana planner dashboard: upstream panels replaced by index freshness + serving panels; alert when index age exceeds ~6 weeks (one missed monthly run).

Risks / Trade-offs

  • [Planet download monthly on the BRouter host uses bandwidth/disk on a shared box] → ~80 GB transient monthly, scoped under ~trails/; the box has 1.8 TB free and the download can use a Geofabrik mirror at off-peak hours. If it becomes unwelcome, per-continent Geofabrik extracts halve the footprint.
  • [Data staleness up to a month (+ alert window)] → Acceptable for these categories; cadence is a timer setting if users report pain.
  • [Row estimate off / table larger than expected on the 40 GB flagship SSD] → Measured before cutover in task order (filter first, count, then size the table); 9 categories ≈ 10M rows ≈ low single-digit GB with indexes. If planet-wide jsonb tags blow the budget, store a tag allowlist (popup fields only).
  • [Self-hosters inherit a pipeline] → Documented as optional: the compose stack works with an empty/absent POI table (overlays show "unavailable"), and the import script accepts any Geofabrik extract URL so a small instance can index just its region cheaply.
  • [Federated/self-host story diverges from flagship topology (no second host)] → The pipeline is two scripts; on a single-host instance both run on that host with a regional extract. Called out in the docs task.

Migration Plan

  1. Land schema + pipeline + route behind no user-visible change (client still on proxy).
  2. Run the first import end-to-end on production data; verify counts, spot-check categories against the live Overpass results for sample viewports.
  3. Switch the client to /api/pois, delete the proxy, swap dashboards — one PR.
  4. Update docs/architecture.md, the privacy notes, and the parked idea README (superseded pointer).

Rollback: revert step-3 PR (proxy code returns, upstream still exists); the index table is additive and harmless to leave in place.

Open Questions

  • None blocking. Whether small self-hosted instances should get a prebuilt regional artifact from trails.cool (so they skip osmium entirely) is a nice-to-have to decide when someone asks.