diff --git a/.env.example b/.env.example
index 5c9ac1caba..e0a8794bdb 100644
--- a/.env.example
+++ b/.env.example
@@ -3,6 +3,25 @@
# ===================================
NEON_DATABASE_URL=postgresql://username:password@host.region.aws.neon.tech/database?sslmode=require
+# ===================================
+# OSM TRAIL DATABASE
+# ===================================
+# Local Docker PostGIS used by osm2pgsql during import (scratch/processing DB).
+# Not exposed publicly — only used by import and migration scripts.
+OSM_DATABASE_URL_LOCAL=postgresql://packrat:packrat@localhost:5433/osm
+
+# osm2pgsql node cache size in MB (default: 800).
+# Increase for continent-scale imports to avoid disk-I/O stalls during way processing.
+# Rule of thumb: ~40% of available RAM. For a 16 GB machine use 6000; for 32 GB use 12000.
+# OSM_CACHE_MB=6000
+
+# Managed production PostGIS (Supabase, Neon, RDS, etc.).
+# Matches OSM_DATABASE_URL in the Worker (injected via Hyperdrive at runtime).
+# When set, `bun run import` automatically syncs osm_ways + osm_routes here
+# after a successful import. Can also be triggered manually with `bun run sync`.
+# Leave unset to skip the sync step during local-only imports.
+# OSM_DATABASE_URL=postgresql://user:password@host/osm?sslmode=require
+
# ===================================
# AUTHENTICATION & SECURITY
# ===================================
diff --git a/apps/admin/app/dashboard/trails/page.tsx b/apps/admin/app/dashboard/trails/page.tsx
new file mode 100644
index 0000000000..c1f21a4bc2
--- /dev/null
+++ b/apps/admin/app/dashboard/trails/page.tsx
@@ -0,0 +1,108 @@
+'use client';
+
+import { Button } from '@packrat/web-ui/components/button';
+import { Input } from '@packrat/web-ui/components/input';
+import { useQuery } from '@tanstack/react-query';
+import { getTrailGeometry, type TrailGeometry } from 'admin-app/lib/api';
+import { queryKeys } from 'admin-app/lib/queryKeys';
+import dynamic from 'next/dynamic';
+import { useState } from 'react';
+
+const LEADING_SLASH_RE = /^r?\//;
+
+const TrailMap = dynamic(() => import('admin-app/components/trail-map').then((m) => m.TrailMap), {
+ ssr: false,
+ loading: () =>
,
+});
+
+function MetaBadge({ label, value }: { label: string; value: string | null | undefined }) {
+ if (!value) return null;
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+export default function TrailViewerPage() {
+ const [input, setInput] = useState('');
+ const [osmId, setOsmId] = useState('');
+
+ const { data, isLoading, isError, error } = useQuery({
+ queryKey: queryKeys.osm.trail(osmId),
+ queryFn: () => getTrailGeometry(osmId),
+ enabled: osmId.length > 0,
+ retry: false,
+ });
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ const trimmed = input.trim().replace(LEADING_SLASH_RE, '');
+ if (trimmed) setOsmId(trimmed);
+ }
+
+ return (
+
+
+
Trail Viewer
+
+ Enter an OSM relation ID to visualise its geometry and verify stitching.
+
+
+
+
+
+ {isError && (
+
+ {error instanceof Error ? error.message : 'Failed to load trail'}
+
+ )}
+
+ {isLoading &&
Loading trail…
}
+
+ {data && (
+ <>
+
+
+ Name
+
+ {data.name ?? unnamed }
+
+
+
+
+
+
+
+ {!data.geometry && (
+
+ ⚠ No geometry — stitching returned null
+
+ )}
+
+
+
+ {data.geometry ? (
+
+ ) : (
+
+ No geometry available for this trail
+
+ )}
+
+ >
+ )}
+
+ );
+}
diff --git a/apps/admin/app/layout.tsx b/apps/admin/app/layout.tsx
index 532c0fff6f..3726ac7874 100644
--- a/apps/admin/app/layout.tsx
+++ b/apps/admin/app/layout.tsx
@@ -3,6 +3,7 @@ import { QueryProvider } from 'admin-app/components/query-provider';
import { ThemeProvider } from 'admin-app/components/theme-provider';
import type { Metadata } from 'next';
import { Mona_Sans as FontSans } from 'next/font/google';
+import Script from 'next/script';
import type React from 'react';
import './globals.css';
@@ -28,6 +29,10 @@ export default function RootLayout({
}>) {
return (
+
+ {/* Leaflet CSS loaded from CDN — the JS bundle uses webpack externals (window.L) */}
+
+
+
);
diff --git a/apps/admin/components/trail-map.tsx b/apps/admin/components/trail-map.tsx
new file mode 100644
index 0000000000..3de750ad7c
--- /dev/null
+++ b/apps/admin/components/trail-map.tsx
@@ -0,0 +1,51 @@
+'use client';
+
+import L from 'leaflet';
+import { useEffect, useRef } from 'react';
+
+interface TrailMapProps {
+ geometry: object;
+ name: string | null;
+}
+
+export function TrailMap({ geometry, name: _name }: TrailMapProps) {
+ const containerRef = useRef(null);
+ const mapRef = useRef(null);
+
+ useEffect(() => {
+ if (!containerRef.current || mapRef.current) return;
+
+ const map = L.map(containerRef.current).setView([0, 0], 2);
+ mapRef.current = map;
+
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ attribution: '© OpenStreetMap ',
+ maxZoom: 19,
+ }).addTo(map);
+
+ return () => {
+ map.remove();
+ mapRef.current = null;
+ };
+ }, []);
+
+ useEffect(() => {
+ const map = mapRef.current;
+ if (!map) return;
+
+ map.eachLayer((layer: L.Layer) => {
+ if (layer instanceof L.GeoJSON) map.removeLayer(layer);
+ });
+
+ const layer = L.geoJSON(geometry as Parameters[0], {
+ style: { color: '#f97316', weight: 3, opacity: 0.9 },
+ }).addTo(map);
+
+ const bounds = layer.getBounds();
+ if (bounds.isValid()) {
+ map.fitBounds(bounds, { padding: [32, 32] });
+ }
+ }, [geometry]);
+
+ return
;
+}
diff --git a/apps/admin/config/nav.ts b/apps/admin/config/nav.ts
index c763cd9acf..9ad84ee066 100644
--- a/apps/admin/config/nav.ts
+++ b/apps/admin/config/nav.ts
@@ -4,6 +4,7 @@ import {
BarChart2,
LayoutDashboard,
type LucideIcon,
+ Map as MapIcon,
Package,
Users,
} from 'lucide-react';
@@ -36,6 +37,11 @@ export const navItems: NavItem[] = [
href: '/dashboard/catalog',
icon: Package,
},
+ {
+ title: 'Trail Viewer',
+ href: '/dashboard/trails',
+ icon: MapIcon,
+ },
{
title: 'Platform Analytics',
href: '/dashboard/analytics/platform',
diff --git a/apps/admin/lib/api.ts b/apps/admin/lib/api.ts
index 3087a08894..6e718eb8f6 100644
--- a/apps/admin/lib/api.ts
+++ b/apps/admin/lib/api.ts
@@ -246,3 +246,29 @@ export function getCatalogEtl(limit = 20): Promise {
export function getCatalogEmbeddings(): Promise {
return adminFetch('/analytics/catalog/embeddings');
}
+
+// ─── OSM Trail Viewer ─────────────────────────────────────────────────────────
+
+async function trailsFetch(path: string): Promise {
+ const authHeaders = getAuthHeader();
+ const res = await fetch(`${API_BASE}${path}`, {
+ headers: { 'Content-Type': 'application/json', ...authHeaders },
+ });
+ if (!res.ok) throw new Error(`Trails API error: ${res.status} ${res.statusText}`);
+ return res.json() as Promise; // safe-cast: fetch returns unknown JSON; caller is responsible for the shape via the generic T
+}
+
+export interface TrailGeometry {
+ osmId: string;
+ name: string | null;
+ sport: string | null;
+ network: string | null;
+ distance: string | null;
+ difficulty: string | null;
+ description: string | null;
+ geometry: object | null;
+}
+
+export function getTrailGeometry(osmId: string): Promise {
+ return trailsFetch(`/trails/${osmId}/geometry`);
+}
diff --git a/apps/admin/lib/queryKeys.ts b/apps/admin/lib/queryKeys.ts
index cf2c9adceb..98edb35de5 100644
--- a/apps/admin/lib/queryKeys.ts
+++ b/apps/admin/lib/queryKeys.ts
@@ -24,6 +24,11 @@ export const queryKeys = {
breakdown: ['platform', 'breakdown'] as const,
},
+ /** OSM trail viewer queries. */
+ osm: {
+ trail: (osmId: string) => ['osm', 'trail', osmId] as const,
+ },
+
/** Catalog analytics queries. */
catalogAnalytics: {
overview: ['catalog', 'overview'] as const,
diff --git a/apps/admin/next-env.d.ts b/apps/admin/next-env.d.ts
new file mode 100644
index 0000000000..36a4fe488a
--- /dev/null
+++ b/apps/admin/next-env.d.ts
@@ -0,0 +1,7 @@
+///
+///
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/apps/admin/next.config.mjs b/apps/admin/next.config.mjs
index d99e085b4f..e1f8934295 100644
--- a/apps/admin/next.config.mjs
+++ b/apps/admin/next.config.mjs
@@ -11,6 +11,23 @@ const nextConfig = {
unoptimized: true,
},
transpilePackages: ['@packrat/web-ui'],
+ webpack: (config, { isServer }) => {
+ // Leaflet is loaded from CDN at runtime (see layout.tsx). Tell webpack to
+ // skip bundling leaflet in both the server and client passes so the build
+ // succeeds even when `leaflet` is absent from node_modules. The trail-map
+ // component uses `{ ssr: false }` so leaflet is never executed server-side.
+ if (isServer) {
+ const prev = Array.isArray(config.externals)
+ ? config.externals
+ : config.externals
+ ? [config.externals]
+ : [];
+ config.externals = [...prev, 'leaflet'];
+ } else {
+ config.externals = { leaflet: 'L' };
+ }
+ return config;
+ },
};
export default nextConfig;
diff --git a/apps/admin/package.json b/apps/admin/package.json
index 022832589a..3c176484e3 100644
--- a/apps/admin/package.json
+++ b/apps/admin/package.json
@@ -28,13 +28,16 @@
"@radix-ui/react-tabs": "catalog:",
"@radix-ui/react-tooltip": "catalog:",
"@tanstack/react-query": "^5.70.0",
+ "@types/leaflet": "^1.9.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "leaflet": "^1.9.4",
"lucide-react": "^1.8.0",
"next": "^15.3.4",
"next-themes": "^0.4.6",
"react": "catalog:",
"react-dom": "catalog:",
+ "react-leaflet": "^5.0.0",
"recharts": "3.8.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
diff --git a/biome.json b/biome.json
index e775361783..e4bce1e6dc 100644
--- a/biome.json
+++ b/biome.json
@@ -16,6 +16,7 @@
"!**/ios",
"!**/android",
"!**/public",
+ "!**/coverage",
"!**/.next",
"!**/.wrangler",
"!**/*.gen.ts",
diff --git a/bun.lock b/bun.lock
index f46ea84992..df820dcc85 100644
--- a/bun.lock
+++ b/bun.lock
@@ -38,13 +38,16 @@
"@radix-ui/react-tabs": "catalog:",
"@radix-ui/react-tooltip": "catalog:",
"@tanstack/react-query": "^5.70.0",
+ "@types/leaflet": "^1.9.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "leaflet": "^1.9.4",
"lucide-react": "^1.8.0",
"next": "^15.3.4",
"next-themes": "^0.4.6",
"react": "catalog:",
"react-dom": "catalog:",
+ "react-leaflet": "^5.0.0",
"recharts": "3.8.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
@@ -490,6 +493,28 @@
"wrangler": "^4.21.2",
},
},
+ "packages/osm-db": {
+ "name": "@packrat/osm-db",
+ "version": "0.1.0",
+ "dependencies": {
+ "@neondatabase/serverless": "^1.0.0",
+ "drizzle-orm": "^0.45.2",
+ "pg": "^8.16.3",
+ "ws": "^8.18.1",
+ },
+ "devDependencies": {
+ "drizzle-kit": "^0.31.10",
+ "typescript": "catalog:",
+ },
+ },
+ "packages/osm-import": {
+ "name": "@packrat/osm-import",
+ "version": "0.1.0",
+ "dependencies": {
+ "@packrat/env": "workspace:*",
+ "pg": "^8.16.3",
+ },
+ },
"packages/overpass": {
"name": "@packrat/overpass",
"version": "0.1.0",
@@ -1296,6 +1321,10 @@
"@packrat/mcp": ["@packrat/mcp@workspace:packages/mcp"],
+ "@packrat/osm-db": ["@packrat/osm-db@workspace:packages/osm-db"],
+
+ "@packrat/osm-import": ["@packrat/osm-import@workspace:packages/osm-import"],
+
"@packrat/overpass": ["@packrat/overpass@workspace:packages/overpass"],
"@packrat/ui": ["@packrat/ui@workspace:packages/ui"],
@@ -1428,6 +1457,8 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
+ "@react-leaflet/core": ["@react-leaflet/core@3.0.0", "", { "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ=="],
+
"@react-native-ai/apple": ["@react-native-ai/apple@0.10.0", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.10", "zod": "^4.0.0" }, "peerDependencies": { "react-native": ">=0.76.0" } }, "sha512-VhtMvzsDnaiU9FLBAstJUYIkQgy/Ce0ll6a/tkx0/uzQF0cChDluft0hXF3/w0p5ZOGIGNrZicvjIiVjrmoA1w=="],
"@react-native-ai/llama": ["@react-native-ai/llama@0.10.0", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.10", "react-native-blob-util": "^0.24.5", "zod": "^4.0.0" }, "peerDependencies": { "llama.rn": "^0.10.0-rc.0", "react-native": ">=0.76.0" } }, "sha512-BlRd+G5xoA/9mpyOLTAUIYtS3tJ3GkTo5z64qJ4jR76f0YpTjz7V2Ky1wHop9GBZWA5dJRke0Yj/EG4J5TsIQg=="],
@@ -1840,6 +1871,8 @@
"@types/jsonfile": ["@types/jsonfile@6.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ=="],
+ "@types/leaflet": ["@types/leaflet@1.9.21", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w=="],
+
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="],
@@ -2906,6 +2939,8 @@
"lan-network": ["lan-network@0.2.1", "", { "bin": { "lan-network": "dist/lan-network-cli.js" } }, "sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A=="],
+ "leaflet": ["leaflet@1.9.4", "", {}, "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="],
+
"lefthook": ["lefthook@1.13.6", "", { "optionalDependencies": { "lefthook-darwin-arm64": "1.13.6", "lefthook-darwin-x64": "1.13.6", "lefthook-freebsd-arm64": "1.13.6", "lefthook-freebsd-x64": "1.13.6", "lefthook-linux-arm64": "1.13.6", "lefthook-linux-x64": "1.13.6", "lefthook-openbsd-arm64": "1.13.6", "lefthook-openbsd-x64": "1.13.6", "lefthook-windows-arm64": "1.13.6", "lefthook-windows-x64": "1.13.6" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-ojj4/4IJ29Xn4drd5emqVgilegAPN3Kf0FQM2p/9+lwSTpU+SZ1v4Ig++NF+9MOa99UKY8bElmVrLhnUUNFh5g=="],
"lefthook-darwin-arm64": ["lefthook-darwin-arm64@1.13.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-m6Lb77VGc84/Qo21Lhq576pEvcgFCnvloEiP02HbAHcIXD0RTLy9u2yAInrixqZeaz13HYtdDaI7OBYAAdVt8A=="],
@@ -3430,6 +3465,8 @@
"react-is": ["react-is@19.2.5", "", {}, "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ=="],
+ "react-leaflet": ["react-leaflet@5.0.0", "", { "dependencies": { "@react-leaflet/core": "^3.0.0" }, "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw=="],
+
"react-native": ["react-native@0.83.6", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.83.6", "@react-native/codegen": "0.83.6", "@react-native/community-cli-plugin": "0.83.6", "@react-native/gradle-plugin": "0.83.6", "@react-native/js-polyfills": "0.83.6", "@react-native/normalize-colors": "0.83.6", "@react-native/virtualized-lists": "0.83.6", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.32.0", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "hermes-compiler": "0.14.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.6", "metro-source-map": "^0.83.6", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.27.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.1", "react": "^19.2.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-H513+8VzviNFXOdPnStRzX9S3/jiJGg++QZ1zd+ROyAvBEKqFqKUPHH0d82y3QyRPct5qKjdOa7J6vNehCvXYA=="],
"react-native-blob-util": ["react-native-blob-util@0.24.7", "", { "dependencies": { "base-64": "0.1.0", "glob": "13.0.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-3vgn3hblfJh0+LIoqEhYRqCtwKh1xID2LtXHdTrUml3rYh4xj69eN+lvWU235AL0FRbX5uKrS1c4lIYexSgtWQ=="],
diff --git a/packages/api/Dockerfile.test b/packages/api/Dockerfile.test
new file mode 100644
index 0000000000..c7b1c16e6e
--- /dev/null
+++ b/packages/api/Dockerfile.test
@@ -0,0 +1,7 @@
+# Extends the pgvector image with PostGIS so integration tests can use
+# both vector similarity and geometry/spatial queries in the same DB.
+FROM pgvector/pgvector:pg15
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends postgresql-15-postgis-3 \
+ && rm -rf /var/lib/apt/lists/*
diff --git a/packages/api/docker-compose.test.yml b/packages/api/docker-compose.test.yml
index 70cbff0273..dad926c705 100644
--- a/packages/api/docker-compose.test.yml
+++ b/packages/api/docker-compose.test.yml
@@ -1,6 +1,8 @@
services:
postgres-test:
- image: pgvector/pgvector:pg15
+ build:
+ context: .
+ dockerfile: Dockerfile.test
environment:
POSTGRES_DB: packrat_test
POSTGRES_USER: test_user
diff --git a/packages/api/drizzle.config.ts b/packages/api/drizzle.config.ts
new file mode 100644
index 0000000000..59d6f0c44c
--- /dev/null
+++ b/packages/api/drizzle.config.ts
@@ -0,0 +1,18 @@
+import { nodeEnv } from '@packrat/env/node';
+import { defineConfig } from 'drizzle-kit';
+
+export default defineConfig({
+ schema: './src/db/schema.ts',
+ out: './drizzle',
+ dialect: 'postgresql',
+ // Exclude OSM tables — they are managed by osm2pgsql, not Drizzle.
+ // Without this, drizzle-kit push would try to drop them.
+ tablesFilter: ['!osm_ways', '!osm_routes'],
+ dbCredentials: {
+ url:
+ nodeEnv.NEON_DATABASE_URL ??
+ (() => {
+ throw new Error('NEON_DATABASE_URL is not set');
+ })(),
+ },
+});
diff --git a/packages/api/drizzle/0037_trips_trail_osm_id.sql b/packages/api/drizzle/0037_trips_trail_osm_id.sql
new file mode 100644
index 0000000000..105775edc5
--- /dev/null
+++ b/packages/api/drizzle/0037_trips_trail_osm_id.sql
@@ -0,0 +1,8 @@
+-- Link trips to any OSM route relation (no FK — OSM data lives in a separate database)
+ALTER TABLE "trips" ADD COLUMN IF NOT EXISTS "trail_osm_id" bigint;
+--> statement-breakpoint
+
+-- Index for reverse lookups (which trips reference a given OSM route)
+CREATE INDEX IF NOT EXISTS "trips_trail_osm_id_idx"
+ ON "trips" (trail_osm_id)
+ WHERE trail_osm_id IS NOT NULL;
diff --git a/packages/api/drizzle/meta/0037_snapshot.json b/packages/api/drizzle/meta/0037_snapshot.json
new file mode 100644
index 0000000000..36e6adff04
--- /dev/null
+++ b/packages/api/drizzle/meta/0037_snapshot.json
@@ -0,0 +1,1805 @@
+{
+ "id": "osm_trails_poc",
+ "prevId": "fa3d18d1-67a7-488a-aba5-5b18295e80f2",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.auth_providers": {
+ "name": "auth_providers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "auth_providers_user_id_users_id_fk": {
+ "name": "auth_providers_user_id_users_id_fk",
+ "tableFrom": "auth_providers",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.catalog_item_etl_jobs": {
+ "name": "catalog_item_etl_jobs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "catalog_item_id": {
+ "name": "catalog_item_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "etl_job_id": {
+ "name": "etl_job_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "catalog_item_etl_jobs_catalog_item_id_catalog_items_id_fk": {
+ "name": "catalog_item_etl_jobs_catalog_item_id_catalog_items_id_fk",
+ "tableFrom": "catalog_item_etl_jobs",
+ "tableTo": "catalog_items",
+ "columnsFrom": ["catalog_item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "catalog_item_etl_jobs_etl_job_id_etl_jobs_id_fk": {
+ "name": "catalog_item_etl_jobs_etl_job_id_etl_jobs_id_fk",
+ "tableFrom": "catalog_item_etl_jobs",
+ "tableTo": "etl_jobs",
+ "columnsFrom": ["etl_job_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.catalog_items": {
+ "name": "catalog_items",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "product_url": {
+ "name": "product_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sku": {
+ "name": "sku",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "weight": {
+ "name": "weight",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "weight_unit": {
+ "name": "weight_unit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "categories": {
+ "name": "categories",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "images": {
+ "name": "images",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "brand": {
+ "name": "brand",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rating_value": {
+ "name": "rating_value",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "size": {
+ "name": "size",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "price": {
+ "name": "price",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "availability": {
+ "name": "availability",
+ "type": "availability",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "seller": {
+ "name": "seller",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "product_sku": {
+ "name": "product_sku",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "material": {
+ "name": "material",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "currency": {
+ "name": "currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "condition": {
+ "name": "condition",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "review_count": {
+ "name": "review_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "variants": {
+ "name": "variants",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "techs": {
+ "name": "techs",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "links": {
+ "name": "links",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reviews": {
+ "name": "reviews",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "qas": {
+ "name": "qas",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "faqs": {
+ "name": "faqs",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "embedding": {
+ "name": "embedding",
+ "type": "vector(1536)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "embedding_idx": {
+ "name": "embedding_idx",
+ "columns": [
+ {
+ "expression": "embedding",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "vector_cosine_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "hnsw",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "catalog_items_sku_unique": {
+ "name": "catalog_items_sku_unique",
+ "nullsNotDistinct": false,
+ "columns": ["sku"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.etl_jobs": {
+ "name": "etl_jobs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "etl_job_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "filename": {
+ "name": "filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "total_processed": {
+ "name": "total_processed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "total_valid": {
+ "name": "total_valid",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "total_invalid": {
+ "name": "total_invalid",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scraper_revision": {
+ "name": "scraper_revision",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "etl_jobs_scraper_revision_idx": {
+ "name": "etl_jobs_scraper_revision_idx",
+ "columns": [
+ {
+ "expression": "scraper_revision",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.invalid_item_logs": {
+ "name": "invalid_item_logs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "job_id": {
+ "name": "job_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "errors": {
+ "name": "errors",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "raw_data": {
+ "name": "raw_data",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "row_index": {
+ "name": "row_index",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "invalid_item_logs_job_id_etl_jobs_id_fk": {
+ "name": "invalid_item_logs_job_id_etl_jobs_id_fk",
+ "tableFrom": "invalid_item_logs",
+ "tableTo": "etl_jobs",
+ "columnsFrom": ["job_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.one_time_passwords": {
+ "name": "one_time_passwords",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "code": {
+ "name": "code",
+ "type": "varchar(6)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "one_time_passwords_user_id_users_id_fk": {
+ "name": "one_time_passwords_user_id_users_id_fk",
+ "tableFrom": "one_time_passwords",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.pack_items": {
+ "name": "pack_items",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "weight": {
+ "name": "weight",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "weight_unit": {
+ "name": "weight_unit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "quantity": {
+ "name": "quantity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "consumable": {
+ "name": "consumable",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "worn": {
+ "name": "worn",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "pack_id": {
+ "name": "pack_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "catalog_item_id": {
+ "name": "catalog_item_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "deleted": {
+ "name": "deleted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_ai_generated": {
+ "name": "is_ai_generated",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "template_item_id": {
+ "name": "template_item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "embedding": {
+ "name": "embedding",
+ "type": "vector(1536)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "pack_items_embedding_idx": {
+ "name": "pack_items_embedding_idx",
+ "columns": [
+ {
+ "expression": "embedding",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "vector_cosine_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "hnsw",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "pack_items_pack_id_packs_id_fk": {
+ "name": "pack_items_pack_id_packs_id_fk",
+ "tableFrom": "pack_items",
+ "tableTo": "packs",
+ "columnsFrom": ["pack_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "pack_items_catalog_item_id_catalog_items_id_fk": {
+ "name": "pack_items_catalog_item_id_catalog_items_id_fk",
+ "tableFrom": "pack_items",
+ "tableTo": "catalog_items",
+ "columnsFrom": ["catalog_item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "pack_items_user_id_users_id_fk": {
+ "name": "pack_items_user_id_users_id_fk",
+ "tableFrom": "pack_items",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "pack_items_template_item_id_pack_template_items_id_fk": {
+ "name": "pack_items_template_item_id_pack_template_items_id_fk",
+ "tableFrom": "pack_items",
+ "tableTo": "pack_template_items",
+ "columnsFrom": ["template_item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.pack_template_items": {
+ "name": "pack_template_items",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "weight": {
+ "name": "weight",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "weight_unit": {
+ "name": "weight_unit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "quantity": {
+ "name": "quantity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "consumable": {
+ "name": "consumable",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "worn": {
+ "name": "worn",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "pack_template_id": {
+ "name": "pack_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "catalog_item_id": {
+ "name": "catalog_item_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "deleted": {
+ "name": "deleted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "pack_template_items_pack_template_id_pack_templates_id_fk": {
+ "name": "pack_template_items_pack_template_id_pack_templates_id_fk",
+ "tableFrom": "pack_template_items",
+ "tableTo": "pack_templates",
+ "columnsFrom": ["pack_template_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "pack_template_items_catalog_item_id_catalog_items_id_fk": {
+ "name": "pack_template_items_catalog_item_id_catalog_items_id_fk",
+ "tableFrom": "pack_template_items",
+ "tableTo": "catalog_items",
+ "columnsFrom": ["catalog_item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "pack_template_items_user_id_users_id_fk": {
+ "name": "pack_template_items_user_id_users_id_fk",
+ "tableFrom": "pack_template_items",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.pack_templates": {
+ "name": "pack_templates",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tags": {
+ "name": "tags",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_app_template": {
+ "name": "is_app_template",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "deleted": {
+ "name": "deleted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "content_source": {
+ "name": "content_source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "content_id": {
+ "name": "content_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "local_created_at": {
+ "name": "local_created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "local_updated_at": {
+ "name": "local_updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "pack_templates_user_id_users_id_fk": {
+ "name": "pack_templates_user_id_users_id_fk",
+ "tableFrom": "pack_templates",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.weight_history": {
+ "name": "weight_history",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pack_id": {
+ "name": "pack_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "weight": {
+ "name": "weight",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "local_created_at": {
+ "name": "local_created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "weight_history_user_id_users_id_fk": {
+ "name": "weight_history_user_id_users_id_fk",
+ "tableFrom": "weight_history",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "weight_history_pack_id_packs_id_fk": {
+ "name": "weight_history_pack_id_packs_id_fk",
+ "tableFrom": "weight_history",
+ "tableTo": "packs",
+ "columnsFrom": ["pack_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.packs": {
+ "name": "packs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_public": {
+ "name": "is_public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tags": {
+ "name": "tags",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted": {
+ "name": "deleted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_ai_generated": {
+ "name": "is_ai_generated",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "local_created_at": {
+ "name": "local_created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "local_updated_at": {
+ "name": "local_updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "packs_user_id_users_id_fk": {
+ "name": "packs_user_id_users_id_fk",
+ "tableFrom": "packs",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "packs_template_id_pack_templates_id_fk": {
+ "name": "packs_template_id_pack_templates_id_fk",
+ "tableFrom": "packs",
+ "tableTo": "pack_templates",
+ "columnsFrom": ["template_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.refresh_tokens": {
+ "name": "refresh_tokens",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "replaced_by_token": {
+ "name": "replaced_by_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "refresh_tokens_user_id_users_id_fk": {
+ "name": "refresh_tokens_user_id_users_id_fk",
+ "tableFrom": "refresh_tokens",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "refresh_tokens_token_unique": {
+ "name": "refresh_tokens_token_unique",
+ "nullsNotDistinct": false,
+ "columns": ["token"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.reported_content": {
+ "name": "reported_content",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_query": {
+ "name": "user_query",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ai_response": {
+ "name": "ai_response",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_comment": {
+ "name": "user_comment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "reviewed": {
+ "name": "reviewed",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "reviewed_by": {
+ "name": "reviewed_by",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reviewed_at": {
+ "name": "reviewed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "reported_content_user_id_users_id_fk": {
+ "name": "reported_content_user_id_users_id_fk",
+ "tableFrom": "reported_content",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "reported_content_reviewed_by_users_id_fk": {
+ "name": "reported_content_reviewed_by_users_id_fk",
+ "tableFrom": "reported_content",
+ "tableTo": "users",
+ "columnsFrom": ["reviewed_by"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.trail_condition_reports": {
+ "name": "trail_condition_reports",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "trail_name": {
+ "name": "trail_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "trail_region": {
+ "name": "trail_region",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "surface": {
+ "name": "surface",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "overall_condition": {
+ "name": "overall_condition",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "hazards": {
+ "name": "hazards",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "water_crossings": {
+ "name": "water_crossings",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "water_crossing_difficulty": {
+ "name": "water_crossing_difficulty",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "photos": {
+ "name": "photos",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "trip_id": {
+ "name": "trip_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted": {
+ "name": "deleted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "local_created_at": {
+ "name": "local_created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "local_updated_at": {
+ "name": "local_updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "trail_condition_reports_user_id_idx": {
+ "name": "trail_condition_reports_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "trail_condition_reports_active_created_idx": {
+ "name": "trail_condition_reports_active_created_idx",
+ "columns": [
+ {
+ "expression": "deleted",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "trail_condition_reports_trail_name_idx": {
+ "name": "trail_condition_reports_trail_name_idx",
+ "columns": [
+ {
+ "expression": "trail_name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "trail_condition_reports_trip_id_idx": {
+ "name": "trail_condition_reports_trip_id_idx",
+ "columns": [
+ {
+ "expression": "trip_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"trail_condition_reports\".\"trip_id\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "trail_condition_reports_user_id_users_id_fk": {
+ "name": "trail_condition_reports_user_id_users_id_fk",
+ "tableFrom": "trail_condition_reports",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "trail_condition_reports_trip_id_trips_id_fk": {
+ "name": "trail_condition_reports_trip_id_trips_id_fk",
+ "tableFrom": "trail_condition_reports",
+ "tableTo": "trips",
+ "columnsFrom": ["trip_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.trips": {
+ "name": "trips",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "start_date": {
+ "name": "start_date",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "end_date": {
+ "name": "end_date",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "location": {
+ "name": "location",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pack_id": {
+ "name": "pack_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "local_created_at": {
+ "name": "local_created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "local_updated_at": {
+ "name": "local_updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "deleted": {
+ "name": "deleted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "trail_osm_id": {
+ "name": "trail_osm_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "trips_user_id_users_id_fk": {
+ "name": "trips_user_id_users_id_fk",
+ "tableFrom": "trips",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "trips_pack_id_packs_id_fk": {
+ "name": "trips_pack_id_packs_id_fk",
+ "tableFrom": "trips",
+ "tableTo": "packs",
+ "columnsFrom": ["pack_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "first_name": {
+ "name": "first_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_name": {
+ "name": "last_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "avatar_url": {
+ "name": "avatar_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'USER'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": ["email"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/packages/api/drizzle/meta/_journal.json b/packages/api/drizzle/meta/_journal.json
index 70f2f73413..b1510673dd 100644
--- a/packages/api/drizzle/meta/_journal.json
+++ b/packages/api/drizzle/meta/_journal.json
@@ -267,6 +267,13 @@
"when": 1775883868581,
"tag": "0036_typical_zuras",
"breakpoints": true
+ },
+ {
+ "idx": 37,
+ "version": "7",
+ "when": 1777170392780,
+ "tag": "0037_trips_trail_osm_id",
+ "breakpoints": true
}
]
}
diff --git a/packages/api/package.json b/packages/api/package.json
index 70edcf5b44..ee38c7f99b 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -18,7 +18,7 @@
"scripts": {
"check-types": "tsc --noEmit",
"check-types-watch": "tsc --noEmit --watch",
- "db:generate": "drizzle-kit generate --dialect=postgresql --schema=src/db/schema.ts --out=./drizzle",
+ "db:generate": "drizzle-kit generate --config=drizzle.config.ts",
"db:migrate": "bun run ./migrate.ts",
"db:seed": "bun run ./src/db/seed.ts",
"db:seed:e2e-user": "bun run ./src/db/seed-e2e-user.ts",
diff --git a/packages/api/src/db/index.ts b/packages/api/src/db/index.ts
index 1a79ce58b3..28c5f0ba8b 100644
--- a/packages/api/src/db/index.ts
+++ b/packages/api/src/db/index.ts
@@ -57,6 +57,24 @@ export const createReadOnlyDb = () => {
return createConnection(NEON_DATABASE_URL_READONLY);
};
+/**
+ * Create a client for the dedicated OSM/trail database.
+ *
+ * Reads OSM_DATABASE_URL — a separate Postgres instance from the main app DB.
+ * For Cloudflare Workers + dedicated Postgres: set this to env.OSM_HYPERDRIVE.connectionString
+ * (add a [[hyperdrive]] binding in wrangler.jsonc pointing at the Postgres instance).
+ * The isStandardPostgresUrl check will route Hyperdrive URLs to pg.Pool automatically.
+ */
+export const createOsmDb = () => {
+ const { OSM_DATABASE_URL } = getEnv();
+ if (!OSM_DATABASE_URL) {
+ throw new Error(
+ 'OSM_DATABASE_URL is not configured — trail features are disabled on this server',
+ );
+ }
+ return createConnection(OSM_DATABASE_URL);
+};
+
/**
* Create SQL client tuned for queue workers (HTTP driver, no pool).
* Used from the queue handler which has direct access to the validated env.
diff --git a/packages/api/src/db/schema.ts b/packages/api/src/db/schema.ts
index a14bf75aac..e6b45ff41e 100644
--- a/packages/api/src/db/schema.ts
+++ b/packages/api/src/db/schema.ts
@@ -2,6 +2,7 @@ import type { PackCategory, WeightUnit } from '@packrat/api/types';
import { type InferInsertModel, type InferSelectModel, relations, sql } from 'drizzle-orm';
import {
type AnyPgColumn,
+ bigint,
boolean,
index,
integer,
@@ -334,6 +335,7 @@ export const trips = pgTable('trips', {
.references(() => users.id)
.notNull(),
packId: text('pack_id').references(() => packs.id, { onDelete: 'set null' }),
+ trailOsmId: bigint('trail_osm_id', { mode: 'bigint' }),
localCreatedAt: timestamp('local_created_at').notNull(),
localUpdatedAt: timestamp('local_updated_at').notNull(),
deleted: boolean('deleted').notNull().default(false),
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
index 02a031f1ea..8b6e6c32cb 100644
--- a/packages/api/src/index.ts
+++ b/packages/api/src/index.ts
@@ -82,13 +82,23 @@ type CfFetchFn = (
ctx: ExecutionContext,
) => Response | Promise;
+function enrichEnv(env: Env): Env {
+ // If the OSM Hyperdrive binding is present, use its connection string so
+ // createOsmDb() routes through Hyperdrive instead of a plain env var URL.
+ if (env.OSM_HYPERDRIVE) {
+ return { ...env, OSM_DATABASE_URL: env.OSM_HYPERDRIVE.connectionString };
+ }
+ return env;
+}
+
export default {
fetch(request: Request, env: Env, ctx: ExecutionContext): Response | Promise {
- setWorkerEnv(env as unknown as Record); // safe-cast: Cloudflare Worker entry point — env is a plain bindings object at runtime
- return (app.fetch as unknown as CfFetchFn)(request, env, ctx); // safe-cast: Elysia's fetch matches the CfFetchFn signature at runtime; unknown intermediate required for variance
+ const e = enrichEnv(env);
+ setWorkerEnv(e as unknown as Record); // safe-cast: Cloudflare Worker entry point — env is a plain bindings object at runtime
+ return (app.fetch as unknown as CfFetchFn)(request, e, ctx); // safe-cast: Elysia's fetch matches the CfFetchFn signature at runtime; unknown intermediate required for variance
},
async queue(batch: MessageBatch, env: Env): Promise {
- setWorkerEnv(env as unknown as Record); // safe-cast: Cloudflare Worker entry point — env is a plain bindings object at runtime
+ setWorkerEnv(enrichEnv(env) as unknown as Record); // safe-cast: Cloudflare Worker entry point — env is a plain bindings object at runtime
if (batch.queue === 'packrat-etl-queue' || batch.queue === 'packrat-etl-queue-dev') {
if (!env.ETL_QUEUE) {
diff --git a/packages/api/src/middleware/__tests__/cfAccess.test.ts b/packages/api/src/middleware/__tests__/cfAccess.test.ts
index 6e1f4a7835..55daf62b54 100644
--- a/packages/api/src/middleware/__tests__/cfAccess.test.ts
+++ b/packages/api/src/middleware/__tests__/cfAccess.test.ts
@@ -121,18 +121,19 @@ function makeRequest(headers: Record = {}): Request {
// Tests
// ---------------------------------------------------------------------------
describe('verifyCFAccessRequest', () => {
+ const opts = { teamDomain: TEAM_DOMAIN, aud: AUD };
+
it('returns { email } for a valid RS256 JWT with correct iss + aud', async () => {
const token = await makeCFJwt({ privateKey });
const result = await verifyCFAccessRequest(
makeRequest({ 'cf-access-jwt-assertion': token }),
- TEAM_DOMAIN,
- AUD,
+ opts,
);
expect(result).toEqual({ email: 'admin@example.com' });
});
it('returns null when the cf-access-jwt-assertion header is absent', async () => {
- const result = await verifyCFAccessRequest(makeRequest(), TEAM_DOMAIN, AUD);
+ const result = await verifyCFAccessRequest(makeRequest(), opts);
expect(result).toBeNull();
});
@@ -140,8 +141,7 @@ describe('verifyCFAccessRequest', () => {
const token = await makeCFJwt({ privateKey, aud: 'wrong-audience' });
const result = await verifyCFAccessRequest(
makeRequest({ 'cf-access-jwt-assertion': token }),
- TEAM_DOMAIN,
- AUD,
+ opts,
);
expect(result).toBeNull();
});
@@ -150,8 +150,7 @@ describe('verifyCFAccessRequest', () => {
const token = await makeCFJwt({ privateKey, iss: 'https://attacker.cloudflareaccess.com' });
const result = await verifyCFAccessRequest(
makeRequest({ 'cf-access-jwt-assertion': token }),
- TEAM_DOMAIN,
- AUD,
+ opts,
);
expect(result).toBeNull();
});
@@ -160,8 +159,7 @@ describe('verifyCFAccessRequest', () => {
const token = await makeCFJwt({ privateKey: untrustedPrivateKey });
const result = await verifyCFAccessRequest(
makeRequest({ 'cf-access-jwt-assertion': token }),
- TEAM_DOMAIN,
- AUD,
+ opts,
);
expect(result).toBeNull();
});
@@ -170,8 +168,7 @@ describe('verifyCFAccessRequest', () => {
const token = await makeCFJwt({ privateKey, omitEmail: true });
const result = await verifyCFAccessRequest(
makeRequest({ 'cf-access-jwt-assertion': token }),
- TEAM_DOMAIN,
- AUD,
+ opts,
);
expect(result).toBeNull();
});
@@ -180,8 +177,7 @@ describe('verifyCFAccessRequest', () => {
const token = await makeCFJwt({ privateKey, email: '' });
const result = await verifyCFAccessRequest(
makeRequest({ 'cf-access-jwt-assertion': token }),
- TEAM_DOMAIN,
- AUD,
+ opts,
);
expect(result).toBeNull();
});
@@ -191,8 +187,7 @@ describe('verifyCFAccessRequest', () => {
// cryptographically verified JWT in cf-access-jwt-assertion.
const result = await verifyCFAccessRequest(
makeRequest({ 'cf-access-authenticated-user-email': 'admin@example.com' }),
- TEAM_DOMAIN,
- AUD,
+ opts,
);
expect(result).toBeNull();
});
diff --git a/packages/api/src/middleware/cfAccess.ts b/packages/api/src/middleware/cfAccess.ts
index cd9d7be318..f64448411a 100644
--- a/packages/api/src/middleware/cfAccess.ts
+++ b/packages/api/src/middleware/cfAccess.ts
@@ -19,22 +19,16 @@ function getJwks(teamDomain: string): ReturnType {
return moduleJwks;
}
-/**
- * Extracts and verifies the CF-Access-JWT-Assertion header from the request
- * against the team's public JWKS. Validates both issuer and audience.
- *
- * Only call when both teamDomain and aud are configured.
- * Returns null when the header is absent or the token fails verification.
- *
- * teamDomain must be the full URL: "https://.cloudflareaccess.com"
- * aud is the CF Access Application Audience tag.
- */
-// biome-ignore lint/complexity/useMaxParams: three semantically distinct required params
+interface CFAccessOptions {
+ teamDomain: string;
+ aud: string;
+}
+
export async function verifyCFAccessRequest(
request: Request,
- teamDomain: string,
- aud: string,
+ opts: CFAccessOptions,
): Promise {
+ const { teamDomain, aud } = opts;
const token = request.headers.get('cf-access-jwt-assertion');
if (!token) return null;
try {
diff --git a/packages/api/src/routes/admin/index.ts b/packages/api/src/routes/admin/index.ts
index 06b8d15dab..e95fe1ba47 100644
--- a/packages/api/src/routes/admin/index.ts
+++ b/packages/api/src/routes/admin/index.ts
@@ -120,11 +120,10 @@ export const adminRoutes = new Elysia({ prefix: '/admin' })
// travels cross-origin; the CF edge then injects Cf-Access-Jwt-Assertion.
// Basic credentials are always required and remain the primary gate.
if (CF_ACCESS_TEAM_DOMAIN && CF_ACCESS_AUD) {
- const cfIdentity = await verifyCFAccessRequest(
- request,
- CF_ACCESS_TEAM_DOMAIN,
- CF_ACCESS_AUD,
- );
+ const cfIdentity = await verifyCFAccessRequest(request, {
+ teamDomain: CF_ACCESS_TEAM_DOMAIN,
+ aud: CF_ACCESS_AUD,
+ });
if (!cfIdentity) return status(401, { error: 'CF Access authentication required' });
}
diff --git a/packages/api/src/routes/trails/index.ts b/packages/api/src/routes/trails/index.ts
index 1c78cd1baa..f30b3bf6b4 100644
--- a/packages/api/src/routes/trails/index.ts
+++ b/packages/api/src/routes/trails/index.ts
@@ -1,142 +1,373 @@
+import { createOsmDb } from '@packrat/api/db';
import { authPlugin } from '@packrat/api/middleware/auth';
-import { queryOverpass, TrailQueryBuilder, toTrailDetail, toTrailSummary } from '@packrat/overpass';
+import { stitchRouteGeometry } from '@packrat/api/services/trails';
+import { sql } from 'drizzle-orm';
import { Elysia, status } from 'elysia';
import { z } from 'zod';
-const OsmSportSchema = z.enum(['hiking', 'cycling', 'skiing', 'running', 'horse_riding']);
+// ── OG meta extraction (AllTrails preview) ───────────────────────────────────
+// Two attribute orderings are valid per the HTML spec: property-then-content and
+// content-then-property. Static top-level regexes avoid dynamic RegExp construction.
-function isOverpassTimeout(err: unknown): boolean {
- const msg = err instanceof Error ? err.message : String(err);
- return msg.includes('504') || msg.toLowerCase().includes('timeout');
-}
+const OG_TITLE_A = / ]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i;
+const OG_TITLE_B = / ]+content=["']([^"']+)["'][^>]+property=["']og:title["']/i;
+const OG_DESC_A = / ]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i;
+const OG_DESC_B = / ]+content=["']([^"']+)["'][^>]+property=["']og:description["']/i;
+const OG_IMAGE_A = / ]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i;
+const OG_IMAGE_B = / ]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i;
+
+// ── Zod schemas ─────────────────────────────────────────────────────────────
+
+const OsmMemberSchema = z.object({
+ type: z.string(),
+ ref: z.number(),
+ role: z.string(),
+});
+
+const RouteBaseRowSchema = z.object({
+ osm_id: z.string(),
+ name: z.string().nullable(),
+ sport: z.string().nullable(),
+ network: z.string().nullable(),
+ distance: z.string().nullable(),
+ difficulty: z.string().nullable(),
+ description: z.string().nullable(),
+});
+
+const RouteSearchRowSchema = RouteBaseRowSchema.extend({
+ bbox: z.string().nullable(),
+});
+
+const RouteDetailRowSchema = RouteBaseRowSchema.extend({
+ members: z.array(OsmMemberSchema).nullable(),
+ geojson: z.string().nullable(),
+});
+
+// ── Routes ─────────────────────────────────────────────────────────────────
export const trailsRoutes = new Elysia({ prefix: '/trails' })
.use(authPlugin)
+
+ /**
+ * GET /api/trails/search
+ *
+ * Fast text + spatial search over osm_routes.
+ * Supports optional sport filter (hiking, cycling, skiing, …).
+ * Returns lightweight results (no geometry) suitable for a search list.
+ */
.get(
'/search',
async ({ query }) => {
- const { q, lat, lon, radius, sport, limit, offset } = query;
+ const { q, lat, lon, radius = 50, sport, limit = 50, offset = 0 } = query;
if (!q && (lat === undefined || lon === undefined)) {
- return status(400, {
- error: 'Provide either q (text search) or lat + lon (spatial search)',
- });
+ return status(400, { error: 'Provide q (text) and/or lat+lon for spatial search' });
}
- const radiusM = Math.min((radius ?? 50) * 1000, 500_000);
+ try {
+ const db = createOsmDb();
+ const conditions: ReturnType[] = [];
+
+ if (q) conditions.push(sql`name ILIKE ${`%${q}%`}`);
- const builder = new TrailQueryBuilder().timeout(25);
+ if (sport) conditions.push(sql`sport = ${sport}`);
- if (sport) builder.sport(sport);
- if (q) builder.name(q);
- if (lat !== undefined && lon !== undefined) builder.around(lat, lon, radiusM);
+ if (lat !== undefined && lon !== undefined) {
+ conditions.push(sql`
+ ST_DWithin(
+ geometry::geography,
+ ST_SetSRID(ST_MakePoint(${lon}, ${lat}), 4326)::geography,
+ ${radius * 1000}
+ )
+ `);
+ }
- const ql = builder.build();
+ const whereClause =
+ conditions.length > 0
+ ? sql`WHERE ${conditions.reduce((acc, c) => sql`${acc} AND ${c}`)}`
+ : sql``;
- try {
- const response = await queryOverpass(ql);
- const trails = response.elements.map(toTrailSummary);
- const off = offset ?? 0;
- const lim = limit ?? 50;
- return trails.slice(off, off + lim);
- } catch (err) {
- if (isOverpassTimeout(err)) {
- return status(504, { error: 'Overpass query timed out' });
+ const result = await db.execute(sql`
+ SELECT
+ osm_id::text,
+ name,
+ sport,
+ network,
+ distance,
+ difficulty,
+ description,
+ ST_AsGeoJSON(ST_Envelope(geometry)) AS bbox
+ FROM osm_routes
+ ${whereClause}
+ ORDER BY
+ CASE WHEN name IS NOT NULL THEN 0 ELSE 1 END,
+ name
+ LIMIT ${limit} OFFSET ${offset}
+ `);
+
+ const rows = z.array(RouteSearchRowSchema).parse(result.rows);
+
+ return rows.map((row) => ({
+ osmId: row.osm_id,
+ name: row.name,
+ sport: row.sport,
+ network: row.network,
+ distance: row.distance,
+ difficulty: row.difficulty,
+ description: row.description,
+ bbox: row.bbox ? JSON.parse(row.bbox) : null,
+ }));
+ } catch (error) {
+ if (error instanceof Error && error.message.includes('not configured')) {
+ return status(503, { error: 'Trail features are not enabled on this server' });
}
- console.error('Overpass search error:', err);
- return status(502, { error: 'Overpass API error' });
+ console.error('Trail search error:', error);
+ return status(500, { error: 'Trail search failed' });
}
},
{
query: z.object({
q: z.string().optional(),
- lat: z.coerce.number().optional(),
- lon: z.coerce.number().optional(),
- radius: z.coerce.number().min(0).max(500).optional(),
- sport: OsmSportSchema.optional(),
- limit: z.coerce.number().int().min(1).max(200).optional().default(50),
- offset: z.coerce.number().int().min(0).optional().default(0),
+ lat: z.coerce.number().min(-90).max(90).optional(),
+ lon: z.coerce.number().min(-180).max(180).optional(),
+ radius: z.coerce.number().positive().max(500).optional(),
+ sport: z.string().optional(),
+ limit: z.coerce.number().int().min(1).max(200).optional(),
+ offset: z.coerce.number().int().min(0).optional(),
}),
isAuthenticated: true,
detail: {
tags: ['Trails'],
- summary: 'Search trails via Overpass',
- description:
- 'Search for OSM hiking/cycling/skiing routes by name and/or location. ' +
- 'Requires q (text) or lat+lon (spatial), or both.',
+ summary: 'Search outdoor routes by text, location, and/or sport',
security: [{ bearerAuth: [] }],
},
},
)
+
+ /**
+ * GET /api/trails/:osmId/geometry
+ *
+ * Returns the full GeoJSON geometry for a route.
+ * Uses stored geometry when available; falls back to runtime ST_LineMerge
+ * stitching from member ways otherwise.
+ */
.get(
- '/:osmId',
+ '/:osmId/geometry',
async ({ params }) => {
- const osmId = Number(params.osmId);
- if (!Number.isInteger(osmId) || osmId <= 0) {
+ let osmId: bigint;
+ try {
+ osmId = BigInt(params.osmId);
+ } catch {
return status(400, { error: 'osmId must be a positive integer' });
}
- const ql = new TrailQueryBuilder().id(osmId).build();
-
try {
- const response = await queryOverpass(ql);
- const [element] = response.elements;
- if (element === undefined) {
- return status(404, { error: `Trail ${osmId} not found` });
+ const db = createOsmDb();
+ const result = await db.execute(sql`
+ SELECT
+ osm_id::text,
+ name,
+ sport,
+ network,
+ distance,
+ difficulty,
+ description,
+ CASE WHEN geometry IS NULL THEN members ELSE NULL END AS members,
+ ST_AsGeoJSON(geometry) AS geojson
+ FROM osm_routes
+ WHERE osm_id = ${osmId}
+ `);
+
+ const row = RouteDetailRowSchema.nullable().parse(result.rows?.[0] ?? null);
+ if (!row) return status(404, { error: 'Trail not found' });
+
+ let geometry: unknown = null;
+
+ if (row.geojson) {
+ geometry = JSON.parse(row.geojson);
+ } else if (row.members && row.members.length > 0) {
+ geometry = await stitchRouteGeometry(db, row.members);
}
- return toTrailSummary(element);
- } catch (err) {
- if (isOverpassTimeout(err)) {
- return status(504, { error: 'Overpass query timed out' });
+
+ return {
+ osmId: row.osm_id,
+ name: row.name,
+ sport: row.sport,
+ network: row.network,
+ distance: row.distance,
+ difficulty: row.difficulty,
+ description: row.description,
+ geometry,
+ };
+ } catch (error) {
+ if (error instanceof Error && error.message.includes('not configured')) {
+ return status(503, { error: 'Trail features are not enabled on this server' });
}
- console.error('Overpass trail fetch error:', err);
- return status(502, { error: 'Overpass API error' });
+ console.error('Trail geometry error:', error);
+ return status(500, { error: 'Failed to fetch trail geometry' });
}
},
{
- params: z.object({ osmId: z.string() }),
+ params: z.object({ osmId: z.string().regex(/^\d+$/, 'osmId must be a positive integer') }),
isAuthenticated: true,
detail: {
tags: ['Trails'],
- summary: 'Get trail metadata',
- description: 'Fetch a single OSM route relation by ID (no geometry).',
+ summary: 'Get full GeoJSON geometry for a route (stitches from OSM ways if needed)',
security: [{ bearerAuth: [] }],
},
},
)
+
+ /**
+ * GET /api/trails/:osmId
+ *
+ * Lightweight route metadata without geometry (for detail screens).
+ */
.get(
- '/:osmId/geometry',
+ '/:osmId',
async ({ params }) => {
- const osmId = Number(params.osmId);
- if (!Number.isInteger(osmId) || osmId <= 0) {
+ let osmId: bigint;
+ try {
+ osmId = BigInt(params.osmId);
+ } catch {
return status(400, { error: 'osmId must be a positive integer' });
}
- const ql = new TrailQueryBuilder().id(osmId).build();
+ try {
+ const db = createOsmDb();
+ const result = await db.execute(sql`
+ SELECT
+ osm_id::text,
+ name,
+ sport,
+ network,
+ distance,
+ difficulty,
+ description,
+ ST_AsGeoJSON(ST_Envelope(geometry)) AS bbox
+ FROM osm_routes
+ WHERE osm_id = ${osmId}
+ `);
+
+ const row = RouteSearchRowSchema.nullable().parse(result.rows?.[0] ?? null);
+ if (!row) return status(404, { error: 'Trail not found' });
+
+ return {
+ osmId: row.osm_id,
+ name: row.name,
+ sport: row.sport,
+ network: row.network,
+ distance: row.distance,
+ difficulty: row.difficulty,
+ description: row.description,
+ bbox: row.bbox ? JSON.parse(row.bbox) : null,
+ };
+ } catch (error) {
+ if (error instanceof Error && error.message.includes('not configured')) {
+ return status(503, { error: 'Trail features are not enabled on this server' });
+ }
+ console.error('Trail fetch error:', error);
+ return status(500, { error: 'Failed to fetch trail' });
+ }
+ },
+ {
+ params: z.object({ osmId: z.string().regex(/^\d+$/, 'osmId must be a positive integer') }),
+ isAuthenticated: true,
+ detail: {
+ tags: ['Trails'],
+ summary: 'Get route metadata by OSM relation ID',
+ security: [{ bearerAuth: [] }],
+ },
+ },
+ )
+
+ /**
+ * POST /api/trails/alltrails-preview
+ *
+ * Fetches an AllTrails URL server-side and extracts OpenGraph metadata.
+ */
+ .post(
+ '/alltrails-preview',
+ async ({ body }) => {
+ const { url } = body;
+
+ let parsed: URL;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return status(400, { error: 'Invalid URL' });
+ }
+
+ const { hostname, protocol } = parsed;
+ if (
+ protocol !== 'https:' ||
+ (hostname !== 'alltrails.com' && !hostname.endsWith('.alltrails.com'))
+ ) {
+ return status(400, { error: 'Only https://alltrails.com URLs are supported' });
+ }
+
+ const AT_UA = 'Mozilla/5.0 (compatible; PackRat/1.0; +https://packrat.world)';
try {
- const response = await queryOverpass(ql);
- const [element] = response.elements;
- if (element === undefined) {
- return status(404, { error: `Trail ${osmId} not found` });
+ let response = await fetch(url, {
+ headers: { 'User-Agent': AT_UA, Accept: 'text/html' },
+ redirect: 'manual',
+ signal: AbortSignal.timeout(8000),
+ });
+
+ if (response.status >= 300 && response.status < 400) {
+ const location = response.headers.get('location');
+ if (!location)
+ return status(502, { error: 'AllTrails redirected without Location header' });
+ let redirectUrl: URL;
+ try {
+ redirectUrl = new URL(location, url);
+ } catch {
+ return status(502, { error: 'Invalid redirect URL' });
+ }
+ if (
+ redirectUrl.protocol !== 'https:' ||
+ (redirectUrl.hostname !== 'alltrails.com' &&
+ !redirectUrl.hostname.endsWith('.alltrails.com'))
+ ) {
+ return status(400, { error: 'Redirect target is not alltrails.com' });
+ }
+ response = await fetch(redirectUrl.toString(), {
+ headers: { 'User-Agent': AT_UA, Accept: 'text/html' },
+ redirect: 'error',
+ signal: AbortSignal.timeout(8000),
+ });
}
- return toTrailDetail(element);
- } catch (err) {
- if (isOverpassTimeout(err)) {
- return status(504, { error: 'Overpass query timed out' });
+
+ if (!response.ok) {
+ return status(502, { error: `AllTrails returned ${response.status}` });
+ }
+
+ const html = await response.text();
+
+ const title = (html.match(OG_TITLE_A) ?? html.match(OG_TITLE_B))?.[1] ?? null;
+ const description = (html.match(OG_DESC_A) ?? html.match(OG_DESC_B))?.[1] ?? null;
+ const image = (html.match(OG_IMAGE_A) ?? html.match(OG_IMAGE_B))?.[1] ?? null;
+
+ if (!title) {
+ return status(422, { error: 'Could not extract trail metadata from page' });
+ }
+
+ return { title, description, image, url: response.url };
+ } catch (error) {
+ if (error instanceof Error && error.name === 'TimeoutError') {
+ return status(504, { error: 'AllTrails request timed out' });
}
- console.error('Overpass trail geometry error:', err);
- return status(502, { error: 'Overpass API error' });
+ console.error('AllTrails preview error:', error);
+ return status(502, { error: 'Failed to fetch AllTrails page' });
}
},
{
- params: z.object({ osmId: z.string() }),
+ body: z.object({ url: z.string().url() }),
isAuthenticated: true,
detail: {
tags: ['Trails'],
- summary: 'Get trail geometry',
- description:
- 'Fetch a single OSM route relation by ID including GeoJSON geometry assembled from member ways.',
+ summary: 'Fetch trail card metadata from an AllTrails URL via OG tags',
security: [{ bearerAuth: [] }],
},
},
diff --git a/packages/api/src/services/trails.ts b/packages/api/src/services/trails.ts
new file mode 100644
index 0000000000..4f48f5e836
--- /dev/null
+++ b/packages/api/src/services/trails.ts
@@ -0,0 +1,61 @@
+import type { createOsmDb } from '@packrat/api/db';
+import { sql } from 'drizzle-orm';
+import { z } from 'zod';
+
+const OsmMemberSchema = z.object({
+ type: z.string(),
+ ref: z.number(),
+ role: z.string(),
+});
+
+export type OsmMember = z.infer;
+
+/**
+ * Stitches a MultiLineString geometry from member way IDs using ST_LineMerge.
+ * Used when an osm_routes row has NULL geometry (osm2pgsql left it unbuilt).
+ * Order is preserved via unnest WITH ORDINALITY.
+ *
+ * Limitation: osm_ways only stores trail-classified ways (hiking paths,
+ * cycleways, piste ways). Road-based cycling routes (ncn/rcn) include road
+ * segments (highway=primary/secondary) that are not in osm_ways, so stitching
+ * will return null for those routes. This only affects the rare null-geometry
+ * fallback path — osm2pgsql assembles geometry for >99% of routes directly.
+ */
+export async function stitchRouteGeometry(
+ db: ReturnType,
+ members: OsmMember[],
+): Promise {
+ const wayRefs = members.filter((m) => m.type === 'w').map((m) => m.ref);
+ if (wayRefs.length === 0) return null;
+
+ const arrayLiteral = sql.join(
+ wayRefs.map((ref) => sql`${ref}`),
+ sql`, `,
+ );
+
+ const result = await db.execute(sql`
+ SELECT ST_AsGeoJSON(
+ ST_LineMerge(
+ ST_Collect(geometry ORDER BY ordinality)
+ )
+ ) AS geojson
+ FROM osm_ways
+ JOIN unnest(
+ ARRAY[${arrayLiteral}]::bigint[]
+ ) WITH ORDINALITY AS t(osm_id, ordinality)
+ USING (osm_id)
+ WHERE geometry IS NOT NULL
+ `);
+
+ const row = z
+ .object({ geojson: z.string().nullable() })
+ .nullable()
+ .parse(result.rows?.[0] ?? null);
+ if (!row?.geojson) return null;
+
+ try {
+ return JSON.parse(row.geojson);
+ } catch {
+ return null;
+ }
+}
diff --git a/packages/api/src/utils/__tests__/env-validation.test.ts b/packages/api/src/utils/__tests__/env-validation.test.ts
index 5aa7134dbf..f4143f5dde 100644
--- a/packages/api/src/utils/__tests__/env-validation.test.ts
+++ b/packages/api/src/utils/__tests__/env-validation.test.ts
@@ -8,6 +8,7 @@ function makeRawEnv(overrides: Record = {}): Record>;
TOKEN_RATE_LIMITER?: { limit(opts: { key: string }): Promise<{ success: boolean }> };
+ OSM_HYPERDRIVE?: Hyperdrive;
};
// Cache for validated envs keyed by the raw env reference.
@@ -155,6 +164,7 @@ function validate(rawEnv: Record): ValidatedEnv {
Container
>,
TOKEN_RATE_LIMITER: rawEnv.TOKEN_RATE_LIMITER as ValidatedEnv['TOKEN_RATE_LIMITER'] | undefined, // safe-cast: Cloudflare Worker binding injected by runtime
+ OSM_HYPERDRIVE: rawEnv.OSM_HYPERDRIVE as Hyperdrive | undefined, // safe-cast: Cloudflare Worker binding injected by runtime
} as ValidatedEnv; // safe-cast: all fields have been individually assigned above with correct runtime binding types
}
diff --git a/packages/api/test/fixtures/trail-fixtures.ts b/packages/api/test/fixtures/trail-fixtures.ts
new file mode 100644
index 0000000000..2048d20caf
--- /dev/null
+++ b/packages/api/test/fixtures/trail-fixtures.ts
@@ -0,0 +1,46 @@
+// Fixture builders for osm_ways and osm_routes tables.
+// These tables are created by the OSM migration and populated by osm2pgsql in
+// production. Tests seed them directly with raw PostGIS SQL.
+
+export interface OsmWayOpts {
+ osmId: number;
+ name?: string | null;
+ sport?: string | null;
+ surface?: string | null;
+ difficulty?: string | null;
+ // WKT LineString — defaults to a short segment in the Sierra Nevada
+ geometryWkt?: string;
+}
+
+export interface OsmMember {
+ type: 'w' | 'r' | 'n';
+ ref: number;
+ role: string;
+}
+
+export interface OsmRouteOpts {
+ osmId: number;
+ name?: string | null;
+ sport?: string | null;
+ network?: string | null;
+ distance?: string | null;
+ difficulty?: string | null;
+ description?: string | null;
+ members?: OsmMember[];
+ // WKT MultiLineString — omit to leave geometry NULL (triggers stitching path)
+ geometryWkt?: string | null;
+}
+
+// ── Geometry helpers ────────────────────────────────────────────────────────
+
+// A short ~15 km segment in the Sierra Nevada, around the John Muir Trail area.
+export const DEFAULT_WAY_WKT =
+ 'LINESTRING(-118.50 37.50, -118.48 37.52, -118.45 37.55, -118.42 37.58, -118.40 37.60)';
+
+// Same coordinates wrapped as a MultiLineString for route geometry.
+export const DEFAULT_ROUTE_WKT =
+ 'MULTILINESTRING((-118.50 37.50, -118.48 37.52, -118.45 37.55, -118.42 37.58, -118.40 37.60))';
+
+// Centroid lat/lon of the test geometry (useful for spatial search tests).
+export const TEST_GEOMETRY_LAT = 37.55;
+export const TEST_GEOMETRY_LON = -118.45;
diff --git a/packages/api/test/setup.ts b/packages/api/test/setup.ts
index 02f9a53148..3f28efdea6 100644
--- a/packages/api/test/setup.ts
+++ b/packages/api/test/setup.ts
@@ -1,4 +1,5 @@
import { neonConfig, Pool } from '@neondatabase/serverless';
+import { isObject } from '@packrat/guards';
import { sql } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/neon-serverless';
import { afterAll, beforeAll, beforeEach, vi } from 'vitest';
@@ -74,9 +75,23 @@ vi.mock('elysia', async (importOriginal) => {
construct(target, args, newTarget) {
const instance = Reflect.construct(target, args, newTarget) as {
config: { aot: boolean };
+ onBeforeHandle: (fn: (ctx: Record) => void) => unknown;
};
// Force dynamic (no-eval) handler path for all instances in workerd.
instance.config.aot = false;
+ // Fix: in non-AOT mode Elysia wraps Decode output in { value: … } for
+ // query and params but never unwraps them before calling the handler
+ // (unlike body, which does `decoded?.value ?? decoded`).
+ // Unwrap both here before every handler fires.
+ instance.onBeforeHandle((ctx) => {
+ const unwrap = (val: unknown): unknown => {
+ if (isObject(val) && 'value' in val && Object.keys(val).length === 1)
+ return (val as { value: unknown }).value;
+ return val;
+ };
+ ctx.query = unwrap(ctx.query);
+ ctx.params = unwrap(ctx.params);
+ });
return instance;
},
});
@@ -518,6 +533,7 @@ vi.mock('@packrat/api/db', () => ({
createDb: vi.fn(() => testDb),
createReadOnlyDb: vi.fn(() => testDb),
createDbClient: vi.fn(() => testDb),
+ createOsmDb: vi.fn(() => testDb),
}));
vi.mock('youtube-transcript', () => ({
@@ -599,6 +615,34 @@ beforeAll(async () => {
console.error('❌ Failed to connect to test database:', error);
throw error;
}
+
+ // Create OSM tables in the test DB so trails tests can seed them.
+ // createOsmDb() is mocked to return testDb, so both table families live here.
+ await testPool.query(`CREATE EXTENSION IF NOT EXISTS postgis`);
+ await testPool.query(`CREATE EXTENSION IF NOT EXISTS pg_trgm`);
+ await testPool.query(`
+ CREATE TABLE IF NOT EXISTS osm_ways (
+ osm_id bigint PRIMARY KEY,
+ name text,
+ sport text,
+ surface text,
+ difficulty text,
+ geometry geometry(LineString,4326)
+ )
+ `);
+ await testPool.query(`
+ CREATE TABLE IF NOT EXISTS osm_routes (
+ osm_id bigint PRIMARY KEY,
+ name text,
+ sport text,
+ network text,
+ distance text,
+ difficulty text,
+ description text,
+ members jsonb,
+ geometry geometry(MultiLineString,4326)
+ )
+ `);
});
beforeEach(async () => {
@@ -630,6 +674,8 @@ beforeEach(async () => {
'posts',
'trips',
'users',
+ 'osm_ways',
+ 'osm_routes',
];
const tableList = tablesToClean.map((t) => `"${t}"`).join(', ');
diff --git a/packages/api/test/trails.test.ts b/packages/api/test/trails.test.ts
new file mode 100644
index 0000000000..d528a54f45
--- /dev/null
+++ b/packages/api/test/trails.test.ts
@@ -0,0 +1,522 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { TEST_GEOMETRY_LAT, TEST_GEOMETRY_LON } from './fixtures/trail-fixtures';
+import { seedOsmRoute, seedOsmWay } from './utils/osm-db-helpers';
+import {
+ apiWithAuth,
+ expectBadRequest,
+ expectJsonResponse,
+ expectNotFound,
+} from './utils/test-helpers';
+
+// ── OSM IDs used across this file ───────────────────────────────────────────
+// Use large numbers to avoid collision with any other test data.
+const WAY_OSM_ID = 9_000_001;
+const WAY2_OSM_ID = 9_000_002;
+// Two geographically disconnected ways — can't be merged by ST_LineMerge
+const WAY_DISCONNECTED_A_ID = 9_000_003;
+const WAY_DISCONNECTED_B_ID = 9_000_004;
+
+const RELATION_WITH_GEOM_ID = 9_100_001;
+const RELATION_NO_GEOM_ID = 9_100_002;
+const RELATION_MULTI_WAY_ID = 9_100_003;
+const RELATION_HIKING_ID = 9_100_004;
+const RELATION_CYCLING_ID = 9_100_005;
+const RELATION_MIXED_MEMBERS_ID = 9_100_006;
+const RELATION_MISSING_WAYS_ID = 9_100_007;
+const RELATION_DISCONNECTED_ID = 9_100_008;
+
+describe('Trails Routes', () => {
+ beforeEach(async () => {
+ // ── Seed ways ─────────────────────────────────────────────────────────
+
+ await seedOsmWay({
+ osmId: WAY_OSM_ID,
+ name: 'Sierra Test Way',
+ surface: 'dirt',
+ // Default WKT: ~15 km segment in Sierra Nevada
+ });
+
+ await seedOsmWay({
+ osmId: WAY2_OSM_ID,
+ name: 'Sierra Test Way 2',
+ surface: 'rock',
+ // Continues from first way — good for multi-way stitching
+ geometryWkt: 'LINESTRING(-118.40 37.60, -118.38 37.62, -118.35 37.65)',
+ });
+
+ // ── Seed relations ─────────────────────────────────────────────────────
+
+ // A relation that osm2pgsql already built geometry for (happy path).
+ await seedOsmRoute({
+ osmId: RELATION_WITH_GEOM_ID,
+ name: 'John Muir Test Trail',
+ network: 'rwn',
+ distance: '20 km',
+ difficulty: 'moderate',
+ description: 'A test trail inspired by the John Muir Trail',
+ members: [{ type: 'w', ref: WAY_OSM_ID, role: '' }],
+ // geometryWkt defaults to DEFAULT_ROUTE_WKT
+ });
+
+ // A relation without stored geometry — triggers runtime stitching.
+ await seedOsmRoute({
+ osmId: RELATION_NO_GEOM_ID,
+ name: 'Unstored Geometry Trail',
+ network: 'lwn',
+ members: [{ type: 'w', ref: WAY_OSM_ID, role: '' }],
+ geometryWkt: null, // NULL → stitching from members
+ });
+
+ // A multi-way relation to test ST_LineMerge across two segments.
+ await seedOsmRoute({
+ osmId: RELATION_MULTI_WAY_ID,
+ name: 'Multi Way Test Trail',
+ network: 'lwn',
+ members: [
+ { type: 'w', ref: WAY_OSM_ID, role: '' },
+ { type: 'w', ref: WAY2_OSM_ID, role: '' },
+ ],
+ geometryWkt: null,
+ });
+
+ // ── Sport filter fixtures ──────────────────────────────────────────────
+ await seedOsmRoute({
+ osmId: RELATION_HIKING_ID,
+ name: 'Pacific Crest Hiking Trail',
+ sport: 'hiking',
+ network: 'nwn',
+ members: [{ type: 'w', ref: WAY_OSM_ID, role: '' }],
+ // geometryWkt defaults to DEFAULT_ROUTE_WKT
+ });
+
+ await seedOsmRoute({
+ osmId: RELATION_CYCLING_ID,
+ name: 'Pacific Crest Cycling Route',
+ sport: 'cycling',
+ network: 'ncn',
+ members: [{ type: 'w', ref: WAY_OSM_ID, role: '' }],
+ });
+
+ // ── Mixed member types (node + way + sub-relation) ─────────────────────
+ // Real OSM relations contain node and sub-relation members; only 'w' rows
+ // should be used during geometry stitching.
+ await seedOsmRoute({
+ osmId: RELATION_MIXED_MEMBERS_ID,
+ name: 'Mixed Member Trail',
+ network: 'lwn',
+ members: [
+ { type: 'n', ref: 12345, role: 'start' },
+ { type: 'w', ref: WAY_OSM_ID, role: '' },
+ { type: 'r', ref: 67890, role: 'alternate' },
+ ],
+ geometryWkt: null,
+ });
+
+ // ── Member ways not in osm_ways (road segments filtered by Lua) ────────
+ // Mirrors road-based cycling/hiking routes (ncn/rcn) whose road members
+ // (highway=primary/secondary) are absent from osm_ways. Stitching must
+ // gracefully return null rather than throw.
+ await seedOsmRoute({
+ osmId: RELATION_MISSING_WAYS_ID,
+ name: 'Road Cycling Route',
+ sport: 'cycling',
+ network: 'ncn',
+ members: [
+ { type: 'w', ref: 999_888_777, role: '' }, // not in osm_ways
+ { type: 'w', ref: 999_888_778, role: '' },
+ ],
+ geometryWkt: null,
+ });
+
+ // ── Disconnected way segments ──────────────────────────────────────────
+ // Sierra Nevada segment
+ await seedOsmWay({
+ osmId: WAY_DISCONNECTED_A_ID,
+ geometryWkt: 'LINESTRING(-118.50 37.50, -118.48 37.52)',
+ });
+ // Geographically unconnected segment in Utah
+ await seedOsmWay({
+ osmId: WAY_DISCONNECTED_B_ID,
+ geometryWkt: 'LINESTRING(-111.50 40.50, -111.48 40.52)',
+ });
+
+ await seedOsmRoute({
+ osmId: RELATION_DISCONNECTED_ID,
+ name: 'Disconnected Segment Route',
+ network: 'rwn',
+ members: [
+ { type: 'w', ref: WAY_DISCONNECTED_A_ID, role: '' },
+ { type: 'w', ref: WAY_DISCONNECTED_B_ID, role: '' },
+ ],
+ geometryWkt: null,
+ });
+ });
+
+ // ── GET /trails/search ────────────────────────────────────────────────────
+
+ describe('GET /trails/search', () => {
+ it('returns 400 when neither q nor lat/lon is provided', async () => {
+ const res = await apiWithAuth('/trails/search');
+ expectBadRequest(res);
+ });
+
+ it('searches by text and returns matching relations', async () => {
+ const res = await apiWithAuth('/trails/search?q=John+Muir+Test');
+ const data = await expectJsonResponse(res);
+
+ expect(Array.isArray(data)).toBe(true);
+ const found = data.find((t: { osmId: string }) => t.osmId === String(RELATION_WITH_GEOM_ID));
+ expect(found).toBeDefined();
+ expect(found.name).toBe('John Muir Test Trail');
+ expect(found.network).toBe('rwn');
+ expect(found.distance).toBe('20 km');
+ });
+
+ it('is case-insensitive in text search', async () => {
+ const res = await apiWithAuth('/trails/search?q=john+muir+test');
+ const data = await expectJsonResponse(res);
+
+ const found = data.find((t: { osmId: string }) => t.osmId === String(RELATION_WITH_GEOM_ID));
+ expect(found).toBeDefined();
+ });
+
+ it('returns empty array for a query that matches nothing', async () => {
+ const res = await apiWithAuth('/trails/search?q=zzz_no_match_zzz');
+ const data = await expectJsonResponse(res);
+ expect(Array.isArray(data)).toBe(true);
+ expect(data).toHaveLength(0);
+ });
+
+ it('does spatial search by lat/lon and returns nearby trails', async () => {
+ // TEST_GEOMETRY_LAT/LON is at the centroid of the seeded geometry
+ const res = await apiWithAuth(
+ `/trails/search?lat=${TEST_GEOMETRY_LAT}&lon=${TEST_GEOMETRY_LON}&radius=50`,
+ );
+ const data = await expectJsonResponse(res);
+
+ expect(Array.isArray(data)).toBe(true);
+ // At least the relation with pre-built geometry should be within 50 km
+ const osmIds = data.map((t: { osmId: string }) => t.osmId);
+ expect(osmIds).toContain(String(RELATION_WITH_GEOM_ID));
+ });
+
+ it('combines text and spatial filters', async () => {
+ // Correct name + close location → match
+ const resHit = await apiWithAuth(
+ `/trails/search?q=John+Muir+Test&lat=${TEST_GEOMETRY_LAT}&lon=${TEST_GEOMETRY_LON}&radius=50`,
+ );
+ const hit = await expectJsonResponse(resHit);
+ expect(hit.some((t: { osmId: string }) => t.osmId === String(RELATION_WITH_GEOM_ID))).toBe(
+ true,
+ );
+
+ // Correct name but very far location → no match
+ const resMiss = await apiWithAuth('/trails/search?q=John+Muir+Test&lat=0&lon=0&radius=1');
+ const miss = await expectJsonResponse(resMiss);
+ expect(miss.some((t: { osmId: string }) => t.osmId === String(RELATION_WITH_GEOM_ID))).toBe(
+ false,
+ );
+ });
+
+ it('returns 400 for out-of-range coordinates', async () => {
+ const res = await apiWithAuth('/trails/search?lat=200&lon=0');
+ expectBadRequest(res);
+ });
+
+ it('returns 400 for radius greater than 500', async () => {
+ const res = await apiWithAuth(
+ `/trails/search?lat=${TEST_GEOMETRY_LAT}&lon=${TEST_GEOMETRY_LON}&radius=501`,
+ );
+ expectBadRequest(res);
+ });
+
+ it('filters by sport and excludes other sports', async () => {
+ const res = await apiWithAuth(
+ `/trails/search?lat=${TEST_GEOMETRY_LAT}&lon=${TEST_GEOMETRY_LON}&radius=500&sport=hiking`,
+ );
+ const data = await expectJsonResponse(res);
+
+ const osmIds = data.map((t: { osmId: string }) => t.osmId);
+ expect(osmIds).toContain(String(RELATION_HIKING_ID));
+ expect(osmIds).not.toContain(String(RELATION_CYCLING_ID));
+ });
+
+ it('returns sport field in search results', async () => {
+ const res = await apiWithAuth('/trails/search?q=Pacific+Crest+Hiking');
+ const data = await expectJsonResponse(res);
+ const found = data.find((t: { osmId: string }) => t.osmId === String(RELATION_HIKING_ID));
+ expect(found).toBeDefined();
+ expect(found.sport).toBe('hiking');
+ });
+
+ it('paginates results with limit and offset', async () => {
+ const res1 = await apiWithAuth(
+ `/trails/search?lat=${TEST_GEOMETRY_LAT}&lon=${TEST_GEOMETRY_LON}&radius=500&limit=1&offset=0`,
+ );
+ const page1 = await expectJsonResponse(res1);
+ expect(page1).toHaveLength(1);
+
+ const res2 = await apiWithAuth(
+ `/trails/search?lat=${TEST_GEOMETRY_LAT}&lon=${TEST_GEOMETRY_LON}&radius=500&limit=1&offset=1`,
+ );
+ const page2 = await expectJsonResponse(res2);
+ expect(page2).toHaveLength(1);
+
+ expect(page1[0].osmId).not.toBe(page2[0].osmId);
+ });
+
+ it('returns bbox when geometry is present', async () => {
+ const res = await apiWithAuth('/trails/search?q=John+Muir+Test');
+ const data = await expectJsonResponse(res);
+ const found = data.find((t: { osmId: string }) => t.osmId === String(RELATION_WITH_GEOM_ID));
+ expect(found.bbox).not.toBeNull();
+ expect(found.bbox.type).toBe('Polygon');
+ });
+
+ it('returns null bbox when geometry is null', async () => {
+ const res = await apiWithAuth('/trails/search?q=Unstored+Geometry');
+ const data = await expectJsonResponse(res);
+ const found = data.find((t: { osmId: string }) => t.osmId === String(RELATION_NO_GEOM_ID));
+ expect(found).toBeDefined();
+ expect(found.bbox).toBeNull();
+ });
+ });
+
+ // ── GET /trails/:osmId ────────────────────────────────────────────────────
+
+ describe('GET /trails/:osmId', () => {
+ it('returns trail metadata for a known OSM ID', async () => {
+ const res = await apiWithAuth(`/trails/${RELATION_WITH_GEOM_ID}`);
+ const data = await expectJsonResponse(res, ['osmId', 'name', 'network']);
+
+ expect(data.osmId).toBe(String(RELATION_WITH_GEOM_ID));
+ expect(data.name).toBe('John Muir Test Trail');
+ expect(data.network).toBe('rwn');
+ expect(data.distance).toBe('20 km');
+ expect(data.difficulty).toBe('moderate');
+ expect(data.description).toBe('A test trail inspired by the John Muir Trail');
+ });
+
+ it('includes bbox in the response when geometry is present', async () => {
+ const res = await apiWithAuth(`/trails/${RELATION_WITH_GEOM_ID}`);
+ const data = await expectJsonResponse(res);
+ expect(data.bbox).not.toBeNull();
+ expect(data.bbox.type).toBe('Polygon');
+ });
+
+ it('returns 404 for an OSM ID that does not exist', async () => {
+ const res = await apiWithAuth('/trails/9999999999');
+ expectNotFound(res);
+ });
+
+ it('returns 400 for a non-numeric OSM ID', async () => {
+ const res = await apiWithAuth('/trails/not-a-number');
+ expectBadRequest(res);
+ });
+ });
+
+ // ── GET /trails/:osmId/geometry ───────────────────────────────────────────
+
+ describe('GET /trails/:osmId/geometry', () => {
+ it('returns pre-built GeoJSON geometry for a relation that has one', async () => {
+ const res = await apiWithAuth(`/trails/${RELATION_WITH_GEOM_ID}/geometry`);
+ const data = await expectJsonResponse(res, ['osmId', 'name', 'geometry']);
+
+ expect(data.osmId).toBe(String(RELATION_WITH_GEOM_ID));
+ expect(data.geometry).not.toBeNull();
+ expect(data.geometry.type).toMatch(/^(MultiLineString|LineString)$/);
+ // GeoJSON coordinates should be an array
+ expect(Array.isArray(data.geometry.coordinates)).toBe(true);
+ });
+
+ it('stitches geometry from member ways when stored geometry is null', async () => {
+ const res = await apiWithAuth(`/trails/${RELATION_NO_GEOM_ID}/geometry`);
+ const data = await expectJsonResponse(res, ['osmId', 'geometry']);
+
+ expect(data.geometry).not.toBeNull();
+ // ST_LineMerge can return LineString or MultiLineString
+ expect(data.geometry.type).toMatch(/^(LineString|MultiLineString)$/);
+ });
+
+ it('stitches and merges two connecting way segments', async () => {
+ const res = await apiWithAuth(`/trails/${RELATION_MULTI_WAY_ID}/geometry`);
+ const data = await expectJsonResponse(res, ['osmId', 'geometry']);
+
+ expect(data.geometry).not.toBeNull();
+ // Two connecting ways → ST_LineMerge should produce a single LineString
+ expect(data.geometry.type).toMatch(/^(LineString|MultiLineString)$/);
+ });
+
+ it('returns stitched geometry on repeated calls when stored geometry is null', async () => {
+ // First call: triggers stitching and caching
+ await apiWithAuth(`/trails/${RELATION_NO_GEOM_ID}/geometry`);
+
+ // Second call: should now hit the cached geometry branch
+ const res2 = await apiWithAuth(`/trails/${RELATION_NO_GEOM_ID}/geometry`);
+ const data2 = await expectJsonResponse(res2, ['osmId', 'geometry']);
+
+ expect(data2.geometry).not.toBeNull();
+ });
+
+ it('returns 404 for a non-existent relation', async () => {
+ const res = await apiWithAuth('/trails/9999999999/geometry');
+ expectNotFound(res);
+ });
+
+ it('returns 400 for a non-numeric OSM ID', async () => {
+ const res = await apiWithAuth('/trails/bad-id/geometry');
+ expectBadRequest(res);
+ });
+
+ it('ignores node and sub-relation members — only way members are stitched', async () => {
+ // RELATION_MIXED_MEMBERS_ID has type:n, type:w, type:r members.
+ // Only the type:w member (WAY_OSM_ID) should be used; non-way members
+ // must not cause an error or empty result.
+ const res = await apiWithAuth(`/trails/${RELATION_MIXED_MEMBERS_ID}/geometry`);
+ const data = await expectJsonResponse(res, ['osmId', 'geometry']);
+
+ expect(data.geometry).not.toBeNull();
+ expect(data.geometry.type).toMatch(/^(LineString|MultiLineString)$/);
+ });
+
+ it('returns null geometry when member ways are not in osm_ways', async () => {
+ // Mirrors road-based cycling routes (ncn/rcn) whose road segments are
+ // absent from osm_ways (Lua config filters highway=primary/secondary).
+ const res = await apiWithAuth(`/trails/${RELATION_MISSING_WAYS_ID}/geometry`);
+ const data = await expectJsonResponse(res, ['osmId', 'geometry']);
+
+ expect(data.geometry).toBeNull();
+ });
+
+ it('returns MultiLineString for geographically disconnected way segments', async () => {
+ // Disconnected ways cannot be merged by ST_LineMerge → MultiLineString.
+ const res = await apiWithAuth(`/trails/${RELATION_DISCONNECTED_ID}/geometry`);
+ const data = await expectJsonResponse(res, ['osmId', 'geometry']);
+
+ expect(data.geometry).not.toBeNull();
+ expect(data.geometry.type).toBe('MultiLineString');
+ });
+ });
+
+ // ── POST /trails/alltrails-preview ────────────────────────────────────────
+
+ describe('POST /trails/alltrails-preview', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('returns 400 for a non-alltrails.com URL', async () => {
+ const res = await apiWithAuth('/trails/alltrails-preview', {
+ method: 'POST',
+ body: JSON.stringify({ url: 'https://example.com/trail' }),
+ headers: { 'Content-Type': 'application/json' },
+ });
+ expectBadRequest(res);
+ });
+
+ it('returns 400 for an invalid (non-URL) string', async () => {
+ const res = await apiWithAuth('/trails/alltrails-preview', {
+ method: 'POST',
+ body: JSON.stringify({ url: 'not-a-url' }),
+ headers: { 'Content-Type': 'application/json' },
+ });
+ expectBadRequest(res);
+ });
+
+ it('returns 400 for a URL on an alltrails subdomain that is not alltrails.com', async () => {
+ const res = await apiWithAuth('/trails/alltrails-preview', {
+ method: 'POST',
+ body: JSON.stringify({ url: 'https://evil.alltrails.com.attacker.com/trail' }),
+ headers: { 'Content-Type': 'application/json' },
+ });
+ expectBadRequest(res);
+ });
+
+ it('extracts OG metadata from a valid AllTrails page', async () => {
+ const mockHtml = `
+
+
+
+
+
+
+
+
+ `;
+
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(
+ new Response(mockHtml, {
+ status: 200,
+ headers: { 'Content-Type': 'text/html' },
+ }),
+ ),
+ );
+
+ const testUrl = 'https://www.alltrails.com/trail/us/utah/angels-landing-trail';
+ const res = await apiWithAuth('/trails/alltrails-preview', {
+ method: 'POST',
+ body: JSON.stringify({ url: testUrl }),
+ headers: { 'Content-Type': 'application/json' },
+ });
+
+ const data = await expectJsonResponse(res, ['title', 'url']);
+ expect(data.title).toBe('Angels Landing Trail');
+ expect(data.description).toBe('One of the most popular hikes in Zion.');
+ expect(data.image).toBe('https://images.alltrails.com/angels-landing.jpg');
+ expect(data.url).toBe(testUrl);
+ });
+
+ it('extracts OG tags regardless of attribute order in the meta tag', async () => {
+ // Some pages write content before property in the attribute order
+ const mockHtml = `
+
+
+
+
+ `;
+
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(mockHtml, { status: 200 })));
+
+ const res = await apiWithAuth('/trails/alltrails-preview', {
+ method: 'POST',
+ body: JSON.stringify({ url: 'https://www.alltrails.com/trail/us/co/summit-peak' }),
+ headers: { 'Content-Type': 'application/json' },
+ });
+
+ const data = await expectJsonResponse(res, ['title']);
+ expect(data.title).toBe('Summit Peak Trail');
+ expect(data.description).toBe('Challenging summit hike.');
+ });
+
+ it('returns 422 when the page has no OG title', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi
+ .fn()
+ .mockResolvedValue(
+ new Response('Page ', { status: 200 }),
+ ),
+ );
+
+ const res = await apiWithAuth('/trails/alltrails-preview', {
+ method: 'POST',
+ body: JSON.stringify({ url: 'https://www.alltrails.com/trail/us/ut/no-og-trail' }),
+ headers: { 'Content-Type': 'application/json' },
+ });
+ expect(res.status).toBe(422);
+ });
+
+ it('returns 502 when AllTrails returns a non-OK status', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('Not Found', { status: 404 })));
+
+ const res = await apiWithAuth('/trails/alltrails-preview', {
+ method: 'POST',
+ body: JSON.stringify({ url: 'https://www.alltrails.com/trail/us/ut/missing' }),
+ headers: { 'Content-Type': 'application/json' },
+ });
+ expect(res.status).toBe(502);
+ });
+ });
+});
diff --git a/packages/api/test/utils/osm-db-helpers.ts b/packages/api/test/utils/osm-db-helpers.ts
new file mode 100644
index 0000000000..5d1748ecec
--- /dev/null
+++ b/packages/api/test/utils/osm-db-helpers.ts
@@ -0,0 +1,95 @@
+// Raw-SQL seeding helpers for osm_ways and osm_routes.
+// Schema is defined in packages/osm-db; seeding uses raw PostGIS SQL
+// since geometry literals can't be expressed through Drizzle's insert API.
+
+import { createOsmDb } from '@packrat/api/db';
+import { sql } from 'drizzle-orm';
+import {
+ DEFAULT_ROUTE_WKT,
+ DEFAULT_WAY_WKT,
+ type OsmMember,
+ type OsmRouteOpts,
+ type OsmWayOpts,
+} from '../fixtures/trail-fixtures';
+
+const SINGLE_QUOTE_RE = /'/g;
+
+function quoted(s: string | null | undefined): string {
+ if (s == null) return 'NULL';
+ return `'${s.replace(SINGLE_QUOTE_RE, "''")}'`;
+}
+
+/**
+ * Seeds a single osm_ways row via raw PostGIS SQL.
+ * Returns the osm_id so callers can reference it in routes.
+ */
+export async function seedOsmWay(opts: OsmWayOpts): Promise {
+ const db = createOsmDb();
+ const wkt = opts.geometryWkt ?? DEFAULT_WAY_WKT;
+
+ await db.execute(
+ sql.raw(`
+ INSERT INTO osm_ways (osm_id, name, sport, surface, difficulty, geometry)
+ VALUES (
+ ${opts.osmId},
+ ${quoted(opts.name)},
+ ${quoted(opts.sport)},
+ ${quoted(opts.surface)},
+ ${quoted(opts.difficulty)},
+ ST_SetSRID(ST_GeomFromText('${wkt.replace(SINGLE_QUOTE_RE, "''")}'), 4326)
+ )
+ ON CONFLICT (osm_id) DO UPDATE SET
+ name = EXCLUDED.name,
+ sport = EXCLUDED.sport,
+ surface = EXCLUDED.surface,
+ geometry = EXCLUDED.geometry
+ `),
+ );
+
+ return opts.osmId;
+}
+
+/**
+ * Seeds a single osm_routes row via raw PostGIS SQL.
+ *
+ * Pass `geometryWkt: null` to leave the geometry column NULL — this exercises
+ * the runtime ST_LineMerge stitching code path in the geometry endpoint.
+ *
+ * Returns the osm_id.
+ */
+export async function seedOsmRoute(opts: OsmRouteOpts): Promise {
+ const db = createOsmDb();
+ const members: OsmMember[] = opts.members ?? [];
+ const membersJson = JSON.stringify(members).replace(SINGLE_QUOTE_RE, "''");
+
+ const wkt = opts.geometryWkt !== undefined ? opts.geometryWkt : DEFAULT_ROUTE_WKT;
+ const geometrySql =
+ wkt != null
+ ? `ST_SetSRID(ST_GeomFromText('${wkt.replace(SINGLE_QUOTE_RE, "''")}'), 4326)`
+ : 'NULL';
+
+ await db.execute(
+ sql.raw(`
+ INSERT INTO osm_routes
+ (osm_id, name, sport, network, distance, difficulty, description, members, geometry)
+ VALUES (
+ ${opts.osmId},
+ ${quoted(opts.name)},
+ ${quoted(opts.sport)},
+ ${quoted(opts.network)},
+ ${quoted(opts.distance)},
+ ${quoted(opts.difficulty)},
+ ${quoted(opts.description)},
+ '${membersJson}'::jsonb,
+ ${geometrySql}
+ )
+ ON CONFLICT (osm_id) DO UPDATE SET
+ name = EXCLUDED.name,
+ sport = EXCLUDED.sport,
+ members = EXCLUDED.members,
+ geometry = EXCLUDED.geometry
+ `),
+ );
+
+ return opts.osmId;
+}
diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json
index 3aaa531482..5687342dfb 100644
--- a/packages/api/tsconfig.json
+++ b/packages/api/tsconfig.json
@@ -3,10 +3,9 @@
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
- "baseUrl": ".",
"paths": {
- "@packrat/api": ["src/index.ts"],
- "@packrat/api/*": ["src/*"]
+ "@packrat/api": ["./src/index.ts"],
+ "@packrat/api/*": ["./src/*"]
}
},
"include": ["src/**/*.ts"],
diff --git a/packages/api/wrangler.jsonc b/packages/api/wrangler.jsonc
index 6f5fb2fccb..077883fa19 100644
--- a/packages/api/wrangler.jsonc
+++ b/packages/api/wrangler.jsonc
@@ -80,6 +80,10 @@
"ai": {
"binding": "AI"
},
+ // OSM / trail database — dedicated Postgres instance with PostGIS.
+ // Add a Hyperdrive binding when ready:
+ // wrangler hyperdrive create osm-db --connection-string="postgresql://..."
+ // Then add: "hyperdrive": [{ "binding": "OSM_HYPERDRIVE", "id": "" }]
// App Container configuration - runs Node.js TikTok API service
"containers": [
{
@@ -181,6 +185,15 @@
"ai": {
"binding": "AI"
},
+ // Local dev: points directly at the Docker PostGIS container.
+ // Replace localConnectionString with your local OSM DB URL.
+ "hyperdrive": [
+ {
+ "binding": "OSM_HYPERDRIVE",
+ "id": "TODO_replace_with_hyperdrive_config_id",
+ "localConnectionString": "postgresql://packrat:packrat@localhost:5433/osm"
+ }
+ ],
"containers": [
{
"name": "packrat-api-container-dev",
diff --git a/packages/config/src/config.ts b/packages/config/src/config.ts
index 0e3a5e9a31..afb633768b 100644
--- a/packages/config/src/config.ts
+++ b/packages/config/src/config.ts
@@ -13,6 +13,7 @@ const FeatureFlag = Object.freeze({
EnableFeed: 'enableFeed',
EnableWildlifeIdentification: 'enableWildlifeIdentification',
EnableLocalAI: 'enableLocalAI',
+ EnableTrails: 'enableTrails',
});
const DashboardTileId = Object.freeze({
@@ -71,6 +72,7 @@ const APP_CONFIG_SOURCE = {
[FeatureFlag.EnableFeed]: false,
[FeatureFlag.EnableWildlifeIdentification]: false,
[FeatureFlag.EnableLocalAI]: false,
+ [FeatureFlag.EnableTrails]: false,
},
dashboard: {
gapPrefix: GAP_PREFIX,
diff --git a/packages/env/scripts/no-raw-process-env.ts b/packages/env/scripts/no-raw-process-env.ts
index 6bab66996c..fa0081652e 100644
--- a/packages/env/scripts/no-raw-process-env.ts
+++ b/packages/env/scripts/no-raw-process-env.ts
@@ -52,6 +52,9 @@ const ALLOWED: string[] = [
'packages/api/src/utils/__tests__/',
// Admin env shim — parses process.env once at module load
'apps/admin/lib/env.ts',
+ // OSM import script — spawns subprocesses and must pass the full OS env (PATH, HOME, etc.)
+ // to Bun.spawn via { ...process.env, ... }. App-level vars (IMPORT_MODE etc.) use nodeEnv.
+ 'packages/osm-import/import.ts',
];
// Directories to skip entirely
diff --git a/packages/env/src/node.ts b/packages/env/src/node.ts
index aec5efe63e..b7747c773c 100644
--- a/packages/env/src/node.ts
+++ b/packages/env/src/node.ts
@@ -37,6 +37,16 @@ export const nodeEnvSchema = z.object({
NEON_DATABASE_URL: z.string().url().optional(),
NEON_DATABASE_URL_READONLY: z.string().url().optional(),
+ // ── OSM trail database (packages/osm-import) ──────────────────────
+ // Managed production PostGIS (mirrors OSM_DATABASE_URL in the Worker via Hyperdrive).
+ OSM_DATABASE_URL: z.string().url().optional(),
+ // Local Docker PostGIS used by osm2pgsql during import (scratch/processing DB).
+ OSM_DATABASE_URL_LOCAL: z.string().url().optional(),
+ // osm2pgsql node cache in MB — increase for continent-scale imports (e.g. 6000).
+ OSM_CACHE_MB: z.string().regex(/^\d+$/).optional(),
+ // Import mode: 'create' drops and recreates tables; 'append' applies incremental diffs.
+ IMPORT_MODE: z.enum(['create', 'append']).default('create'),
+
// ── R2 / S3 credentials (packages/analytics/scripts/smoke-test.ts) ─
R2_ACCESS_KEY_ID: z.string().min(1).optional(),
R2_SECRET_ACCESS_KEY: z.string().min(1).optional(),
@@ -77,6 +87,10 @@ export const nodeEnv = nodeEnvSchema.parse({
PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: process.env.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN,
NEON_DATABASE_URL: process.env.NEON_DATABASE_URL,
NEON_DATABASE_URL_READONLY: process.env.NEON_DATABASE_URL_READONLY,
+ OSM_DATABASE_URL: process.env.OSM_DATABASE_URL,
+ OSM_DATABASE_URL_LOCAL: process.env.OSM_DATABASE_URL_LOCAL,
+ OSM_CACHE_MB: process.env.OSM_CACHE_MB,
+ IMPORT_MODE: process.env.IMPORT_MODE,
R2_ACCESS_KEY_ID: process.env.R2_ACCESS_KEY_ID,
R2_SECRET_ACCESS_KEY: process.env.R2_SECRET_ACCESS_KEY,
R2_ENDPOINT_URL: process.env.R2_ENDPOINT_URL,
diff --git a/packages/mcp/src/tools/trails.ts b/packages/mcp/src/tools/trails.ts
index d2ce0048fc..67f8b8c1a1 100644
--- a/packages/mcp/src/tools/trails.ts
+++ b/packages/mcp/src/tools/trails.ts
@@ -3,6 +3,116 @@ import { err, ok } from '../client';
import type { AgentContext } from '../types';
export function registerTrailTools(agent: AgentContext): void {
+ // ── Search trails ─────────────────────────────────────────────────────────
+
+ agent.server.registerTool(
+ 'search_trails',
+ {
+ description:
+ 'Search outdoor trails and routes from OpenStreetMap. Filter by name, sport type, and/or proximity to a location. Returns lightweight metadata (no geometry) suitable for a search list.',
+ inputSchema: {
+ q: z
+ .string()
+ .optional()
+ .describe('Text to search in route names (e.g. "John Muir Trail", "Pacific Crest")'),
+ lat: z
+ .number()
+ .min(-90)
+ .max(90)
+ .optional()
+ .describe('Latitude for spatial search (requires lon)'),
+ lon: z
+ .number()
+ .min(-180)
+ .max(180)
+ .optional()
+ .describe('Longitude for spatial search (requires lat)'),
+ radius: z
+ .number()
+ .positive()
+ .max(500)
+ .optional()
+ .describe('Search radius in kilometres (default 50, max 500)'),
+ sport: z
+ .string()
+ .optional()
+ .describe('Filter by sport type: hiking, cycling, skiing, or other OSM sport values'),
+ limit: z
+ .number()
+ .int()
+ .min(1)
+ .max(200)
+ .optional()
+ .describe('Maximum results to return (default 50)'),
+ offset: z.number().int().min(0).optional().describe('Pagination offset (default 0)'),
+ },
+ },
+ async ({ q, lat, lon, radius, sport, limit, offset }) => {
+ try {
+ const data = await agent.api.get('/trails/search', {
+ q,
+ lat,
+ lon,
+ radius,
+ sport,
+ limit,
+ offset,
+ });
+ return ok(data);
+ } catch (e) {
+ return err(e);
+ }
+ },
+ );
+
+ // ── Get trail metadata ────────────────────────────────────────────────────
+
+ agent.server.registerTool(
+ 'get_trail',
+ {
+ description:
+ 'Get metadata for a specific trail by its OSM relation ID. Returns name, sport, difficulty, distance, and bounding box. Does not include full geometry — use get_trail_geometry for that.',
+ inputSchema: {
+ osm_id: z
+ .string()
+ .describe('OSM relation ID of the route (e.g. "12345678"). Get from search_trails.'),
+ },
+ },
+ async ({ osm_id }) => {
+ try {
+ const data = await agent.api.get(`/trails/${osm_id}`);
+ return ok(data);
+ } catch (e) {
+ return err(e);
+ }
+ },
+ );
+
+ // ── Get trail geometry ────────────────────────────────────────────────────
+
+ agent.server.registerTool(
+ 'get_trail_geometry',
+ {
+ description:
+ 'Get the full GeoJSON geometry for a trail. Uses pre-built geometry when available; otherwise stitches it from member OSM ways. May be slow for large routes with many segments.',
+ inputSchema: {
+ osm_id: z
+ .string()
+ .describe('OSM relation ID of the route (e.g. "12345678"). Get from search_trails.'),
+ },
+ },
+ async ({ osm_id }) => {
+ try {
+ const data = await agent.api.get(`/trails/${osm_id}/geometry`);
+ return ok(data);
+ } catch (e) {
+ return err(e);
+ }
+ },
+ );
+
+ // ── AllTrails preview ─────────────────────────────────────────────────────
+
agent.server.registerTool(
'search_trails',
{
@@ -105,7 +215,10 @@ export function registerTrailTools(agent: AgentContext): void {
description:
'Fetch trail metadata (title, description, image) from an AllTrails URL using OpenGraph tags. Use this to enrich a trip or pack with information from an AllTrails link a user shares.',
inputSchema: {
- url: z.string().url().describe('Full AllTrails URL (must be https://alltrails.com/...)'),
+ url: z
+ .string()
+ .url()
+ .describe('Full AllTrails URL (must be https://alltrails.com/... or a subdomain)'),
},
},
async ({ url }) => {
diff --git a/packages/osm-db/drizzle.config.ts b/packages/osm-db/drizzle.config.ts
new file mode 100644
index 0000000000..afe5f15797
--- /dev/null
+++ b/packages/osm-db/drizzle.config.ts
@@ -0,0 +1,14 @@
+import { nodeEnv } from '@packrat/env/node';
+import { defineConfig } from 'drizzle-kit';
+
+// OSM_DATABASE_URL_LOCAL must be set to the dedicated OSM Postgres instance.
+// For Cloudflare Workers: use the Hyperdrive connectionString.
+const url = nodeEnv.OSM_DATABASE_URL_LOCAL;
+if (!url) throw new Error('OSM_DATABASE_URL_LOCAL is required');
+
+export default defineConfig({
+ schema: './src/schema.ts',
+ out: './drizzle',
+ dialect: 'postgresql',
+ dbCredentials: { url },
+});
diff --git a/packages/osm-db/drizzle/0000_extensions.sql b/packages/osm-db/drizzle/0000_extensions.sql
new file mode 100644
index 0000000000..e9b29bcab1
--- /dev/null
+++ b/packages/osm-db/drizzle/0000_extensions.sql
@@ -0,0 +1,5 @@
+-- Extensions must be enabled before any geometry columns or trgm indexes can be created.
+-- drizzle-kit cannot generate CREATE EXTENSION statements — this file is hand-written
+-- and must run first. All subsequent migrations are generated via drizzle-kit generate.
+CREATE EXTENSION IF NOT EXISTS postgis;
+CREATE EXTENSION IF NOT EXISTS pg_trgm;
diff --git a/packages/osm-db/drizzle/0001_osm_schema.sql b/packages/osm-db/drizzle/0001_osm_schema.sql
new file mode 100644
index 0000000000..59282d869f
--- /dev/null
+++ b/packages/osm-db/drizzle/0001_osm_schema.sql
@@ -0,0 +1,30 @@
+CREATE TABLE IF NOT EXISTS "osm_routes" (
+ "osm_id" bigint PRIMARY KEY NOT NULL,
+ "name" text,
+ "sport" text,
+ "network" text,
+ "distance" text,
+ "difficulty" text,
+ "description" text,
+ "members" jsonb,
+ "geometry" geometry(MultiLineString,4326)
+);
+--> statement-breakpoint
+CREATE TABLE IF NOT EXISTS "osm_ways" (
+ "osm_id" bigint PRIMARY KEY NOT NULL,
+ "name" text,
+ "sport" text,
+ "surface" text,
+ "difficulty" text,
+ "geometry" geometry(LineString,4326)
+);
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "osm_routes_geometry_idx" ON "osm_routes" USING gist ("geometry");--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "osm_routes_geography_idx" ON "osm_routes" USING gist (("geometry"::geography));--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "osm_routes_sport_idx" ON "osm_routes" USING btree ("sport") WHERE "osm_routes"."sport" IS NOT NULL;--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "osm_routes_network_idx" ON "osm_routes" USING btree ("network") WHERE "osm_routes"."network" IS NOT NULL;--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "osm_routes_name_trgm_idx" ON "osm_routes" USING gin ("name" gin_trgm_ops) WHERE "osm_routes"."name" IS NOT NULL;--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "osm_ways_geometry_idx" ON "osm_ways" USING gist ("geometry");--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "osm_ways_geography_idx" ON "osm_ways" USING gist (("geometry"::geography));--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "osm_ways_sport_idx" ON "osm_ways" USING btree ("sport") WHERE "osm_ways"."sport" IS NOT NULL;--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "osm_ways_name_trgm_idx" ON "osm_ways" USING gin ("name" gin_trgm_ops) WHERE "osm_ways"."name" IS NOT NULL;
\ No newline at end of file
diff --git a/packages/osm-db/drizzle/meta/0001_snapshot.json b/packages/osm-db/drizzle/meta/0001_snapshot.json
new file mode 100644
index 0000000000..b6c9ecc2c5
--- /dev/null
+++ b/packages/osm-db/drizzle/meta/0001_snapshot.json
@@ -0,0 +1,279 @@
+{
+ "id": "35ef1807-0781-40a5-9fb4-5a3b9e3076fc",
+ "prevId": "00000000-0000-0000-0000-000000000000",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.osm_routes": {
+ "name": "osm_routes",
+ "schema": "",
+ "columns": {
+ "osm_id": {
+ "name": "osm_id",
+ "type": "bigint",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sport": {
+ "name": "sport",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "network": {
+ "name": "network",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "distance": {
+ "name": "distance",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "difficulty": {
+ "name": "difficulty",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "members": {
+ "name": "members",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "geometry": {
+ "name": "geometry",
+ "type": "geometry(MultiLineString,4326)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "osm_routes_geometry_idx": {
+ "name": "osm_routes_geometry_idx",
+ "columns": [
+ {
+ "expression": "geometry",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gist",
+ "with": {}
+ },
+ "osm_routes_geography_idx": {
+ "name": "osm_routes_geography_idx",
+ "columns": [
+ {
+ "expression": "(\"geometry\"::geography)",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gist",
+ "with": {}
+ },
+ "osm_routes_sport_idx": {
+ "name": "osm_routes_sport_idx",
+ "columns": [
+ {
+ "expression": "sport",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"osm_routes\".\"sport\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "osm_routes_network_idx": {
+ "name": "osm_routes_network_idx",
+ "columns": [
+ {
+ "expression": "network",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"osm_routes\".\"network\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "osm_routes_name_trgm_idx": {
+ "name": "osm_routes_name_trgm_idx",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "gin_trgm_ops"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"osm_routes\".\"name\" IS NOT NULL",
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.osm_ways": {
+ "name": "osm_ways",
+ "schema": "",
+ "columns": {
+ "osm_id": {
+ "name": "osm_id",
+ "type": "bigint",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sport": {
+ "name": "sport",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "surface": {
+ "name": "surface",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "difficulty": {
+ "name": "difficulty",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "geometry": {
+ "name": "geometry",
+ "type": "geometry(LineString,4326)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "osm_ways_geometry_idx": {
+ "name": "osm_ways_geometry_idx",
+ "columns": [
+ {
+ "expression": "geometry",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gist",
+ "with": {}
+ },
+ "osm_ways_geography_idx": {
+ "name": "osm_ways_geography_idx",
+ "columns": [
+ {
+ "expression": "(\"geometry\"::geography)",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gist",
+ "with": {}
+ },
+ "osm_ways_sport_idx": {
+ "name": "osm_ways_sport_idx",
+ "columns": [
+ {
+ "expression": "sport",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"osm_ways\".\"sport\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "osm_ways_name_trgm_idx": {
+ "name": "osm_ways_name_trgm_idx",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "gin_trgm_ops"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"osm_ways\".\"name\" IS NOT NULL",
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/packages/osm-db/drizzle/meta/_journal.json b/packages/osm-db/drizzle/meta/_journal.json
new file mode 100644
index 0000000000..b96653eb22
--- /dev/null
+++ b/packages/osm-db/drizzle/meta/_journal.json
@@ -0,0 +1,20 @@
+{
+ "version": "7",
+ "dialect": "postgresql",
+ "entries": [
+ {
+ "idx": 0,
+ "version": "7",
+ "when": 1777232534000,
+ "tag": "0000_extensions",
+ "breakpoints": false
+ },
+ {
+ "idx": 1,
+ "version": "7",
+ "when": 1777232534976,
+ "tag": "0001_osm_schema",
+ "breakpoints": true
+ }
+ ]
+}
diff --git a/packages/osm-db/migrate.ts b/packages/osm-db/migrate.ts
new file mode 100644
index 0000000000..3eb327c560
--- /dev/null
+++ b/packages/osm-db/migrate.ts
@@ -0,0 +1,58 @@
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { neon, neonConfig } from '@neondatabase/serverless';
+import { nodeEnv } from '@packrat/env/node';
+import { drizzle } from 'drizzle-orm/neon-http';
+import { migrate } from 'drizzle-orm/neon-http/migrator';
+import { drizzle as drizzlePg } from 'drizzle-orm/node-postgres';
+import { migrate as migratePg } from 'drizzle-orm/node-postgres/migrator';
+import { Client } from 'pg';
+import WebSocket from 'ws';
+
+neonConfig.webSocketConstructor = WebSocket;
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+const isStandardPostgresUrl = (url: string) => {
+ try {
+ const u = new URL(url);
+ const host = u.hostname.toLowerCase();
+ return (
+ (u.protocol === 'postgres:' || u.protocol === 'postgresql:') &&
+ !host.endsWith('.neon.tech') &&
+ !host.endsWith('.neon.com')
+ );
+ } catch {
+ return false;
+ }
+};
+
+async function runMigrations() {
+ const url = nodeEnv.OSM_DATABASE_URL_LOCAL;
+ if (!url) throw new Error('OSM_DATABASE_URL_LOCAL is required');
+
+ console.log('Running OSM DB migrations...');
+
+ if (isStandardPostgresUrl(url)) {
+ console.log('Using PostgreSQL migrations...');
+ const client = new Client({ connectionString: url });
+ await client.connect();
+ const db = drizzlePg(client);
+ await migratePg(db, { migrationsFolder: join(__dirname, 'drizzle') });
+ await client.end();
+ } else {
+ console.log('Using Neon serverless migrations...');
+ const sql = neon(url);
+ const db = drizzle(sql);
+ await migrate(db, { migrationsFolder: join(__dirname, 'drizzle') });
+ }
+
+ console.log('OSM DB migrations completed.');
+ process.exit(0);
+}
+
+runMigrations().catch((err) => {
+ console.error('Migration failed:', err);
+ process.exit(1);
+});
diff --git a/packages/osm-db/package.json b/packages/osm-db/package.json
new file mode 100644
index 0000000000..bdfe197519
--- /dev/null
+++ b/packages/osm-db/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "@packrat/osm-db",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "default": "./src/index.ts"
+ },
+ "./*": {
+ "types": "./src/*",
+ "default": "./src/*"
+ }
+ },
+ "scripts": {
+ "check-types": "tsc --noEmit",
+ "db:generate": "drizzle-kit generate --config=drizzle.config.ts",
+ "db:migrate": "bun run ./migrate.ts"
+ },
+ "dependencies": {
+ "@neondatabase/serverless": "^1.0.0",
+ "drizzle-orm": "^0.45.2",
+ "pg": "^8.16.3",
+ "ws": "^8.18.1"
+ },
+ "devDependencies": {
+ "drizzle-kit": "^0.31.10",
+ "typescript": "catalog:"
+ }
+}
diff --git a/packages/osm-db/src/index.ts b/packages/osm-db/src/index.ts
new file mode 100644
index 0000000000..e27a6e2f57
--- /dev/null
+++ b/packages/osm-db/src/index.ts
@@ -0,0 +1 @@
+export * from './schema';
diff --git a/packages/osm-db/src/schema.ts b/packages/osm-db/src/schema.ts
new file mode 100644
index 0000000000..61e47d30bc
--- /dev/null
+++ b/packages/osm-db/src/schema.ts
@@ -0,0 +1,63 @@
+import { sql } from 'drizzle-orm';
+import { bigint, customType, index, jsonb, pgTable, text } from 'drizzle-orm/pg-core';
+
+// PostGIS geometry — drizzle-kit 0.28+ emits customType dataType() verbatim when the
+// type string starts with a known native PG type name ('geometry' is on that list).
+const geometry = customType<{ data: string; driverData: string }>({
+ dataType(config?: { type?: string; srid?: number }) {
+ const type = config?.type ?? 'Geometry';
+ const srid = config?.srid ?? 4326;
+ return `geometry(${type},${srid})`;
+ },
+});
+
+// ── osm_ways ─────────────────────────────────────────────────────────────────
+
+export const osmWays = pgTable(
+ 'osm_ways',
+ {
+ osmId: bigint('osm_id', { mode: 'bigint' }).primaryKey().notNull(),
+ name: text('name'),
+ sport: text('sport'),
+ surface: text('surface'),
+ difficulty: text('difficulty'),
+ geometry: geometry('geometry', { type: 'LineString', srid: 4326 }),
+ },
+ (t) => [
+ // Geometry GiST index for spatial queries
+ index('osm_ways_geometry_idx').using('gist', t.geometry),
+ // Functional geography index — enables ST_DWithin(::geography, ..., meters)
+ index('osm_ways_geography_idx').using('gist', sql`(${t.geometry}::geography)`),
+ index('osm_ways_sport_idx').on(t.sport).where(sql`${t.sport} IS NOT NULL`),
+ // gin_trgm_ops enables fast ILIKE '%query%' via pg_trgm extension
+ index('osm_ways_name_trgm_idx')
+ .using('gin', t.name.op('gin_trgm_ops'))
+ .where(sql`${t.name} IS NOT NULL`),
+ ],
+);
+
+// ── osm_routes ───────────────────────────────────────────────────────────────
+
+export const osmRoutes = pgTable(
+ 'osm_routes',
+ {
+ osmId: bigint('osm_id', { mode: 'bigint' }).primaryKey().notNull(),
+ name: text('name'),
+ sport: text('sport'),
+ network: text('network'),
+ distance: text('distance'),
+ difficulty: text('difficulty'),
+ description: text('description'),
+ members: jsonb('members').$type>(),
+ geometry: geometry('geometry', { type: 'MultiLineString', srid: 4326 }),
+ },
+ (t) => [
+ index('osm_routes_geometry_idx').using('gist', t.geometry),
+ index('osm_routes_geography_idx').using('gist', sql`(${t.geometry}::geography)`),
+ index('osm_routes_sport_idx').on(t.sport).where(sql`${t.sport} IS NOT NULL`),
+ index('osm_routes_network_idx').on(t.network).where(sql`${t.network} IS NOT NULL`),
+ index('osm_routes_name_trgm_idx')
+ .using('gin', t.name.op('gin_trgm_ops'))
+ .where(sql`${t.name} IS NOT NULL`),
+ ],
+);
diff --git a/packages/osm-db/tsconfig.json b/packages/osm-db/tsconfig.json
new file mode 100644
index 0000000000..287a7cee98
--- /dev/null
+++ b/packages/osm-db/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "skipLibCheck": true,
+ "outDir": "dist"
+ },
+ "include": ["src/**/*.ts", "migrate.ts", "drizzle.config.ts"]
+}
diff --git a/packages/osm-import/import.ts b/packages/osm-import/import.ts
new file mode 100644
index 0000000000..164087a217
--- /dev/null
+++ b/packages/osm-import/import.ts
@@ -0,0 +1,167 @@
+#!/usr/bin/env bun
+/**
+ * import.ts — Import OSM trail data into the dedicated OSM PostgreSQL database.
+ *
+ * Prerequisites:
+ * - osm2pgsql >= 1.9 installed (flex output)
+ * - OSM_DATABASE_URL_LOCAL set in root .env (see .env.example)
+ *
+ * Usage:
+ * bun run import # downloads Utah extract
+ * bun run import [path/to/region.pbf] # imports a specific PBF
+ *
+ * Set OSM_DATABASE_URL in .env to auto-sync to production after import.
+ * Set IMPORT_MODE=append for incremental .osc diff imports.
+ *
+ * Index lifecycle:
+ * osm2pgsql --create drops and recreates the output tables, so any indexes
+ * applied beforehand are lost. This script runs a post-import step that
+ * re-applies all custom indexes (geography GiST, trigram, sport/network
+ * B-tree) with IF NOT EXISTS so it is safe to re-run on append imports too.
+ * Running `db:migrate` separately is not required.
+ */
+
+import { existsSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { nodeEnv } from '@packrat/env/node';
+import pg from 'pg';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+// ── Config ─────────────────────────────────────────────────────────────────
+
+const DB_URL = nodeEnv.OSM_DATABASE_URL_LOCAL;
+if (!DB_URL) {
+ console.error('Error: OSM_DATABASE_URL_LOCAL is not set — add it to your root .env');
+ process.exit(1);
+}
+
+const LUA_CONFIG = join(__dirname, 'routes.lua');
+const IMPORT_MODE = nodeEnv.IMPORT_MODE;
+// Node cache in MB. Default (800) is fine for small extracts; use 4000-8000 for
+// continent-scale imports to avoid extreme disk I/O during the way-processing pass.
+const CACHE_MB = nodeEnv.OSM_CACHE_MB ?? '800';
+const UTAH_PBF_URL = 'https://download.geofabrik.de/north-america/us/utah-latest.osm.pbf';
+
+// ── PBF file ────────────────────────────────────────────────────────────────
+
+let pbfPath = process.argv[2];
+
+if (!pbfPath) {
+ pbfPath = join(__dirname, 'utah-latest.osm.pbf');
+ if (!existsSync(pbfPath)) {
+ console.log('Downloading Utah extract from Geofabrik (~150 MB)...');
+ const res = await fetch(UTAH_PBF_URL);
+ if (!res.ok) throw new Error(`Download failed: ${res.status} ${res.statusText}`);
+ await Bun.write(pbfPath, res);
+ console.log(`Saved to ${pbfPath}`);
+ }
+} else if (!existsSync(pbfPath)) {
+ console.error(`Error: file not found: ${pbfPath}`);
+ process.exit(1);
+}
+
+console.log(`PBF file: ${pbfPath}`);
+console.log(`Lua config: ${LUA_CONFIG}`);
+console.log(`Mode: ${IMPORT_MODE}`);
+console.log(`Cache: ${CACHE_MB} MB`);
+console.log('');
+
+// ── Import ──────────────────────────────────────────────────────────────────
+
+const modeFlags = IMPORT_MODE === 'append' ? ['--append'] : ['--create', '--drop'];
+
+const proc = Bun.spawn(
+ [
+ 'osm2pgsql',
+ '--slim',
+ `--cache=${CACHE_MB}`,
+ ...modeFlags,
+ '-O',
+ 'flex',
+ '-S',
+ LUA_CONFIG,
+ '-d',
+ DB_URL,
+ pbfPath,
+ ],
+ { stdout: 'inherit', stderr: 'inherit' },
+);
+
+const exitCode = await proc.exited;
+if (exitCode !== 0) {
+ console.error(`osm2pgsql exited with code ${exitCode}`);
+ process.exit(exitCode);
+}
+
+// ── Post-import migrations ───────────────────────────────────────────────────
+// osm2pgsql --create drops and recreates output tables, losing any pre-existing
+// indexes. Clear the migration journal so osm-db migrations re-run and restore
+// the full index set. The SQL uses IF NOT EXISTS so it is safe on both first
+// imports and re-imports.
+
+console.log('\nRe-applying migrations to restore indexes...');
+const journalClient = new pg.Client({ connectionString: DB_URL });
+await journalClient.connect();
+try {
+ await journalClient.query(`DELETE FROM drizzle.__drizzle_migrations`);
+} catch {
+ // Journal table doesn't exist yet on a brand-new database — that's fine.
+} finally {
+ await journalClient.end();
+}
+
+const migrateProc = Bun.spawn(['bun', 'run', './migrate.ts'], {
+ cwd: join(__dirname, '../osm-db'),
+ env: { ...process.env, OSM_DATABASE_URL_LOCAL: DB_URL },
+ stdout: 'inherit',
+ stderr: 'inherit',
+});
+if ((await migrateProc.exited) !== 0) {
+ console.error('Migration failed after import');
+ process.exit(1);
+}
+console.log('Migrations applied.');
+
+// ── Verify ──────────────────────────────────────────────────────────────────
+
+console.log('\nRow counts:');
+const client = new pg.Client({ connectionString: DB_URL });
+await client.connect();
+
+try {
+ const ways = await client.query(
+ `SELECT sport, count(*)::int AS n FROM osm_ways GROUP BY sport ORDER BY sport`,
+ );
+ const routes = await client.query(
+ `SELECT sport, count(*)::int AS n FROM osm_routes GROUP BY sport ORDER BY sport`,
+ );
+
+ console.log('\nosm_ways:');
+ console.table(ways.rows);
+ console.log('\nosm_routes:');
+ console.table(routes.rows);
+} finally {
+ await client.end();
+}
+
+console.log('\nImport complete.');
+
+// ── Sync to production (optional) ───────────────────────────────────────────
+// Set OSM_DATABASE_URL in .env to automatically promote the local
+// output tables to the managed PostgreSQL instance after every import.
+
+if (nodeEnv.OSM_DATABASE_URL) {
+ console.log('\nOSM_DATABASE_URL detected — syncing to production...');
+ const syncProc = Bun.spawn(['bun', 'run', './sync.ts'], {
+ cwd: __dirname,
+ env: { ...process.env },
+ stdout: 'inherit',
+ stderr: 'inherit',
+ });
+ if ((await syncProc.exited) !== 0) {
+ console.error('Sync to production failed — local import succeeded, re-run: bun run sync');
+ process.exit(1);
+ }
+}
diff --git a/packages/osm-import/package.json b/packages/osm-import/package.json
new file mode 100644
index 0000000000..ec46898367
--- /dev/null
+++ b/packages/osm-import/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "@packrat/osm-import",
+ "version": "0.1.0",
+ "private": true,
+ "description": "osm2pgsql flex config and import tooling for PackRat outdoor routes",
+ "type": "module",
+ "scripts": {
+ "import": "bun run import.ts",
+ "sync": "bun run sync.ts"
+ },
+ "dependencies": {
+ "@packrat/env": "workspace:*",
+ "pg": "^8.16.3"
+ }
+}
diff --git a/packages/osm-import/routes.lua b/packages/osm-import/routes.lua
new file mode 100644
index 0000000000..dbfdfe22c1
--- /dev/null
+++ b/packages/osm-import/routes.lua
@@ -0,0 +1,128 @@
+-- osm2pgsql flex config — imports outdoor routes into PackRat.
+--
+-- Produces two tables:
+-- osm_ways — individual line segments (highway ways, piste ways)
+-- osm_routes — named route relations (hiking, cycling, skiing, …)
+--
+-- Run via import.sh or:
+-- osm2pgsql --slim --drop --create -O flex -S routes.lua
+
+local ways_table = osm2pgsql.define_table({
+ name = 'osm_ways',
+ ids = { type = 'way', id_column = 'osm_id' },
+ columns = {
+ { column = 'name', type = 'text' },
+ { column = 'sport', type = 'text' },
+ { column = 'surface', type = 'text' },
+ { column = 'difficulty', type = 'text' },
+ { column = 'geometry', type = 'linestring', projection = 4326 },
+ },
+})
+
+local routes_table = osm2pgsql.define_table({
+ name = 'osm_routes',
+ ids = { type = 'relation', id_column = 'osm_id' },
+ columns = {
+ { column = 'name', type = 'text' },
+ { column = 'sport', type = 'text' },
+ { column = 'network', type = 'text' },
+ { column = 'distance', type = 'text' },
+ { column = 'difficulty', type = 'text' },
+ { column = 'description', type = 'text' },
+ { column = 'members', type = 'jsonb' },
+ { column = 'geometry', type = 'multilinestring', projection = 4326 },
+ },
+})
+
+-- ── Helpers ──────────────────────────────────────────────────────────────────
+
+-- Derive a normalised sport tag from a way's tags.
+-- Returns nil to skip ways that are not relevant outdoor routes.
+local function way_sport(tags)
+ local hw = tags['highway']
+ local piste = tags['piste:type']
+
+ if piste then return 'skiing' end
+
+ if hw == 'path' or hw == 'footway' or hw == 'steps' then
+ -- Prefer explicit bicycle/foot access tags to distinguish shared paths
+ if tags['bicycle'] == 'designated' then return 'cycling' end
+ return 'hiking'
+ end
+
+ if hw == 'cycleway' then return 'cycling' end
+
+ if hw == 'track' then
+ if tags['bicycle'] == 'designated' or tags['bicycle'] == 'yes' then
+ return 'cycling'
+ end
+ return 'hiking'
+ end
+
+ return nil
+end
+
+-- Derive sport from a route relation's tags.
+local function relation_sport(tags)
+ local route = tags['route']
+ if route == 'hiking' or route == 'foot' then return 'hiking' end
+ if route == 'bicycle' or route == 'mtb' then return 'cycling' end
+ if route == 'ski' or route == 'piste' then return 'skiing' end
+ -- piste:type on the relation itself (some mappers use this)
+ if tags['piste:type'] then return 'skiing' end
+ return nil
+end
+
+-- ── Way processing ────────────────────────────────────────────────────────────
+
+function osm2pgsql.process_way(object)
+ local sport = way_sport(object.tags)
+ if not sport then return end
+
+ ways_table:insert({
+ name = object.tags['name'],
+ sport = sport,
+ surface = object.tags['surface'],
+ difficulty = object.tags['sac_scale']
+ or object.tags['mtb:scale']
+ or object.tags['piste:difficulty'],
+ geometry = object:as_linestring(),
+ })
+end
+
+-- ── Relation processing ───────────────────────────────────────────────────────
+
+function osm2pgsql.select_relation_members(relation)
+ if relation.tags['type'] == 'route' then
+ return { ways = osm2pgsql.way_member_ids(relation) }
+ end
+end
+
+function osm2pgsql.process_relation(object)
+ if object.tags['type'] ~= 'route' then return end
+
+ local sport = relation_sport(object.tags)
+ if not sport then return end
+
+ -- Serialise member ways as a JSON array for runtime stitching fallback
+ local members = {}
+ for _, member in ipairs(object.members) do
+ members[#members + 1] = {
+ type = member.type,
+ ref = member.ref,
+ role = member.role,
+ }
+ end
+
+ routes_table:insert({
+ name = object.tags['name'],
+ sport = sport,
+ network = object.tags['network'],
+ distance = object.tags['distance'] or object.tags['length'],
+ difficulty = object.tags['difficulty']
+ or object.tags['piste:difficulty'],
+ description = object.tags['description'],
+ members = members,
+ geometry = object:as_multilinestring(),
+ })
+end
diff --git a/packages/osm-import/sync.ts b/packages/osm-import/sync.ts
new file mode 100644
index 0000000000..3754d9d89e
--- /dev/null
+++ b/packages/osm-import/sync.ts
@@ -0,0 +1,94 @@
+#!/usr/bin/env bun
+/**
+ * sync.ts — Push local OSM output tables to the managed production database.
+ *
+ * Dumps osm_ways + osm_routes from the local PostGIS instance (OSM_DATABASE_URL_LOCAL)
+ * and restores them into the managed database (OSM_DATABASE_URL).
+ * Run after a successful import to promote local data to production.
+ *
+ * Prerequisites:
+ * - pg_dump / pg_restore installed (postgresql-client)
+ * - Managed DB has the PostGIS extension enabled
+ * - Both OSM_DATABASE_URL_LOCAL and OSM_DATABASE_URL set in root .env
+ *
+ * Usage (standalone):
+ * bun run sync
+ *
+ * When OSM_DATABASE_URL is set, `bun run import` calls this automatically.
+ */
+
+import { rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { nodeEnv } from '@packrat/env/node';
+
+const LOCAL_URL = nodeEnv.OSM_DATABASE_URL_LOCAL;
+const PRODUCTION_URL = nodeEnv.OSM_DATABASE_URL;
+
+if (!LOCAL_URL) {
+ console.error('Error: OSM_DATABASE_URL_LOCAL is not set — add it to your root .env');
+ process.exit(1);
+}
+if (!PRODUCTION_URL) {
+ console.error('Error: OSM_DATABASE_URL is not set — add it to your root .env');
+ process.exit(1);
+}
+
+const OUTPUT_TABLES = ['osm_ways', 'osm_routes'];
+const dumpPath = join(tmpdir(), `osm-sync-${Date.now()}.dump`);
+
+// ── Dump ────────────────────────────────────────────────────────────────────
+
+console.log(`\nDumping ${OUTPUT_TABLES.join(', ')} from local DB...`);
+console.log(` Dump file: ${dumpPath}`);
+
+const dump = Bun.spawn(
+ [
+ 'pg_dump',
+ '--format=custom', // compressed, supports parallel restore
+ '--no-owner',
+ '--no-privileges',
+ ...OUTPUT_TABLES.flatMap((t) => ['--table', t]),
+ '--file',
+ dumpPath,
+ LOCAL_URL,
+ ],
+ { stdout: 'inherit', stderr: 'inherit' },
+);
+
+if ((await dump.exited) !== 0) {
+ console.error('\npg_dump failed — local DB may still be importing.');
+ process.exit(1);
+}
+
+// ── Restore ─────────────────────────────────────────────────────────────────
+
+console.log('\nRestoring to production DB...');
+console.log(' (--clean will drop existing tables before recreating them)');
+
+const restore = Bun.spawn(
+ [
+ 'pg_restore',
+ '--clean',
+ '--if-exists', // safe against empty managed DB on first run
+ '--no-owner',
+ '--no-privileges',
+ '-d',
+ PRODUCTION_URL,
+ dumpPath,
+ ],
+ { stdout: 'inherit', stderr: 'inherit' },
+);
+
+const restoreCode = await restore.exited;
+
+try {
+ rmSync(dumpPath);
+} catch {}
+
+if (restoreCode !== 0) {
+ console.error('\npg_restore failed — dump file has been cleaned up.');
+ process.exit(1);
+}
+
+console.log('\nSync complete — production DB is up to date.');
diff --git a/tsconfig.json b/tsconfig.json
index bffcdfa9b2..90b5240c22 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,7 +1,6 @@
{
"compilerOptions": {
"allowJs": true,
- "baseUrl": ".",
"esModuleInterop": true,
"ignoreDeprecations": "5.0",
"jsx": "react-native",
@@ -58,6 +57,9 @@
"**/.expo",
"**/coverage",
"packages/api/container_src",
- "packages/mcp"
+ "packages/mcp",
+ "packages/osm-db",
+ "packages/osm-import",
+ "packages/overpass"
]
}