poi-index: map-core selectors, planner.pois schema, /api/pois route + client, metrics

Replace the planner's Overpass proxy with a self-hosted POI index:
- map-core POI categories become structured tag selectors (single source of
  truth) + osmium filter / classification helpers
- planner.pois PostGIS table (centroid points, GiST + category indexes)
- /api/pois serving route (session + same-origin + rate limit, 100 cap)
- lib/pois.ts client (renamed from overpass.ts; GET, quantized bbox)
- metrics: poi_api_requests_total + DB-collected index rows/age gauges;
  drop overpass_* metrics
- remove /api/overpass proxy + dead OVERPASS_URLS on the planner service
  (Journal surface backfill still uses it via code default)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-07-12 22:38:03 +02:00
parent 7dd9ccd2b0
commit b45e69885d
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
29 changed files with 887 additions and 933 deletions

View file

@ -1,4 +1,4 @@
import { pgSchema, text, timestamp, boolean, customType, index } from "drizzle-orm/pg-core";
import { pgSchema, text, timestamp, boolean, jsonb, customType, index, primaryKey } from "drizzle-orm/pg-core";
const bytea = customType<{ data: Buffer }>({
dataType() {
@ -6,6 +6,15 @@ const bytea = customType<{ data: Buffer }>({
},
});
// Centroid point in WGS84. Ways/relations are reduced to their centroid at
// extract time (matching the old Overpass `out center`), so every POI is a
// single point regardless of its OSM geometry.
const point = customType<{ data: string }>({
dataType() {
return "geometry(Point, 4326)";
},
});
export const plannerSchema = pgSchema("planner");
export const sessions = plannerSchema.table("sessions", {
@ -22,3 +31,30 @@ export const sessions = plannerSchema.table("sessions", {
// total sessions ever created.
lastActivityIdx: index("sessions_last_activity_idx").on(t.lastActivity),
}));
/**
* Self-hosted POI index. Fed monthly by the extract pipeline (osmium filter on
* the BRouter host) + the flagship import job's atomic staging swap never
* written at request time. Served read-only by `/api/pois` for the planner's
* map overlays, replacing the former Overpass proxy.
*
* An OSM element that matches more than one category is stored once per
* category (part of the composite primary key), because the client treats each
* category's markers independently.
*/
export const pois = plannerSchema.table("pois", {
// OSM element type: 'n' (node), 'w' (way), or 'r' (relation).
osmType: text("osm_type").notNull(),
osmId: text("osm_id").notNull(),
category: text("category").notNull(),
name: text("name"),
geom: point("geom").notNull(),
tags: jsonb("tags").$type<Record<string, string>>().notNull(),
importedAt: timestamp("imported_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
pk: primaryKey({ columns: [t.osmType, t.osmId, t.category] }),
// GiST spatial index for the bbox intersection in the serving query.
geomIdx: index("pois_geom_idx").using("gist", t.geom),
// btree on category so `category = ANY($cats)` is index-driven.
categoryIdx: index("pois_category_idx").on(t.category),
}));