fifteen/infra/schema.sql
2026-03-01 21:58:53 +01:00

125 lines
5.3 KiB
SQL

-- Enable PostGIS
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS postgis_topology;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- ─── Cities ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS cities (
slug TEXT PRIMARY KEY,
name TEXT NOT NULL,
country_code CHAR(2) NOT NULL DEFAULT '',
geofabrik_url TEXT NOT NULL,
bbox geometry(Polygon, 4326),
resolution_m INTEGER NOT NULL DEFAULT 200,
status TEXT NOT NULL DEFAULT 'empty'
CHECK (status IN ('empty','pending','processing','ready','error')),
error_message TEXT,
last_ingested TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Migration for existing databases
ALTER TABLE cities ADD COLUMN IF NOT EXISTS resolution_m INTEGER NOT NULL DEFAULT 200;
CREATE INDEX IF NOT EXISTS idx_cities_bbox ON cities USING GIST (bbox);
-- ─── Raw POIs (created and managed by osm2pgsql flex output) ─────────────────
-- osm2pgsql --drop recreates this table on each ingest using the Lua script.
-- Columns: osm_id (bigint), osm_type (char), city_slug, category, subcategory,
-- name, tags, geom — no auto-generated id column.
-- This CREATE TABLE IF NOT EXISTS is a no-op after the first osm2pgsql run.
CREATE TABLE IF NOT EXISTS raw_pois (
osm_id BIGINT NOT NULL,
osm_type CHAR(1) NOT NULL,
city_slug TEXT NOT NULL,
category TEXT NOT NULL,
subcategory TEXT NOT NULL,
name TEXT,
tags JSONB,
geom geometry(Point, 4326) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_raw_pois_city_cat
ON raw_pois (city_slug, category);
CREATE INDEX IF NOT EXISTS idx_raw_pois_geom
ON raw_pois USING GIST (geom);
CREATE INDEX IF NOT EXISTS idx_raw_pois_name
ON raw_pois USING GIN (name gin_trgm_ops)
WHERE name IS NOT NULL;
-- ─── Grid points ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS grid_points (
id BIGSERIAL PRIMARY KEY,
city_slug TEXT NOT NULL REFERENCES cities(slug) ON DELETE CASCADE,
geom geometry(Point, 4326) NOT NULL,
grid_x INTEGER NOT NULL,
grid_y INTEGER NOT NULL,
UNIQUE (city_slug, grid_x, grid_y)
);
CREATE INDEX IF NOT EXISTS idx_grid_city ON grid_points (city_slug);
CREATE INDEX IF NOT EXISTS idx_grid_geom ON grid_points USING GIST (geom);
-- ─── Pre-computed accessibility scores ───────────────────────────────────────
CREATE TABLE IF NOT EXISTS grid_scores (
grid_point_id BIGINT NOT NULL REFERENCES grid_points(id) ON DELETE CASCADE,
category TEXT NOT NULL,
travel_mode TEXT NOT NULL CHECK (travel_mode IN ('walking','cycling','driving')),
threshold_min INTEGER NOT NULL,
profile TEXT NOT NULL DEFAULT 'universal',
nearest_poi_id BIGINT,
distance_m FLOAT,
travel_time_s FLOAT,
score FLOAT NOT NULL CHECK (score >= 0 AND score <= 1),
computed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (grid_point_id, category, travel_mode, threshold_min, profile)
);
-- Migration for existing databases (adds profile column and rebuilds PK)
ALTER TABLE grid_scores ADD COLUMN IF NOT EXISTS profile TEXT NOT NULL DEFAULT 'universal';
CREATE INDEX IF NOT EXISTS idx_grid_scores_lookup
ON grid_scores (grid_point_id, travel_mode, threshold_min, profile);
-- ─── Nearest POI per subcategory per grid point ───────────────────────────────
-- Populated by compute-scores job. Stores the nearest (by routing time) POI for
-- each subcategory at each grid point, for each travel mode. Threshold-independent.
CREATE TABLE IF NOT EXISTS grid_poi_details (
grid_point_id BIGINT NOT NULL REFERENCES grid_points(id) ON DELETE CASCADE,
category TEXT NOT NULL,
subcategory TEXT NOT NULL,
travel_mode TEXT NOT NULL,
nearest_poi_id BIGINT,
nearest_poi_name TEXT,
distance_m FLOAT,
travel_time_s FLOAT,
computed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (grid_point_id, category, subcategory, travel_mode)
);
CREATE INDEX IF NOT EXISTS idx_grid_poi_details_lookup
ON grid_poi_details (grid_point_id, travel_mode);
-- ─── Isochrone cache ──────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS isochrone_cache (
id BIGSERIAL PRIMARY KEY,
origin_geom geometry(Point, 4326) NOT NULL,
travel_mode TEXT NOT NULL,
contours_min INTEGER[] NOT NULL,
result JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_isochrone_origin
ON isochrone_cache USING GIST (origin_geom);
CREATE INDEX IF NOT EXISTS idx_isochrone_created
ON isochrone_cache (created_at);
-- Auto-expire isochrone cache entries older than 30 days
-- (handled by periodic cleanup or TTL logic in app)