Fix PostGIS geom population: use parseGpxAsync + raw SQL

The initial approach (#150) didn't work because:
1. routes.server.ts used parseGpx (sync, needs browser DOMParser)
   instead of parseGpxAsync (uses linkedom on Node.js) — GPX parsing
   silently failed, so stats AND geom were never populated
2. Drizzle's customType doesn't pass SQL expressions through toDriver

Fixes:
- Switch to parseGpxAsync for all server-side GPX parsing
- Use db.execute() with raw ST_GeomFromGeoJSON SQL after insert
- Remove unused lineStringFromCoords helper from schema
- Share setGeomFromGpx between routes and activities
- Add unit tests for GPX→GeoJSON coordinate extraction

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-02 17:32:07 +01:00
parent feeca58064
commit 6ed828ef9c
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
4 changed files with 104 additions and 52 deletions

View file

@ -1,9 +1,9 @@
import { randomUUID } from "node:crypto";
import { eq, desc } from "drizzle-orm";
import { getDb } from "./db.ts";
import { activities, routes, lineStringFromCoords } from "@trails-cool/db/schema/journal";
import { activities, routes } from "@trails-cool/db/schema/journal";
import { parseGpxAsync } from "@trails-cool/gpx";
import type { SQL } from "drizzle-orm";
import { setGeomFromGpx } from "./routes.server.ts";
export interface ActivityInput {
name: string;
@ -20,8 +20,6 @@ export async function createActivity(ownerId: string, input: ActivityInput) {
let elevationGain: number | null = null;
let elevationLoss: number | null = null;
let startedAt: Date | null = null;
let geom: SQL | null = null;
if (input.gpx) {
try {
const gpxData = await parseGpxAsync(input.gpx);
@ -30,9 +28,6 @@ export async function createActivity(ownerId: string, input: ActivityInput) {
elevationGain = gpxData.elevation.gain;
elevationLoss = gpxData.elevation.loss;
const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]);
if (coords.length >= 2) geom = lineStringFromCoords(coords);
// Try to extract start time from first track point
if (gpxData.tracks[0]?.[0]?.time) {
startedAt = new Date(gpxData.tracks[0][0].time);
@ -53,9 +48,12 @@ export async function createActivity(ownerId: string, input: ActivityInput) {
elevationGain,
elevationLoss,
startedAt,
geom,
});
if (input.gpx) {
await setGeomFromGpx(id, "activities", input.gpx);
}
return id;
}
@ -88,17 +86,6 @@ export async function createRouteFromActivity(activityId: string, ownerId: strin
if (!activity?.gpx) return null;
const routeId = randomUUID();
let routeGeom: SQL | null = null;
if (activity.gpx) {
try {
const gpxData = await parseGpxAsync(activity.gpx);
const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]);
if (coords.length >= 2) routeGeom = lineStringFromCoords(coords);
} catch {
// Continue without geom
}
}
await db.insert(routes).values({
id: routeId,
ownerId,
@ -108,9 +95,10 @@ export async function createRouteFromActivity(activityId: string, ownerId: strin
distance: activity.distance,
elevationGain: activity.elevationGain,
elevationLoss: activity.elevationLoss,
geom: routeGeom,
});
await setGeomFromGpx(routeId, "routes", activity.gpx);
// Link the activity to the new route
await db
.update(activities)

View file

@ -1,9 +1,9 @@
import { randomUUID } from "node:crypto";
import { eq, desc, and } from "drizzle-orm";
import { getDb } from "./db.ts";
import { routes, routeVersions, lineStringFromCoords } from "@trails-cool/db/schema/journal";
import { parseGpx } from "@trails-cool/gpx";
import type { SQL } from "drizzle-orm";
import { routes, routeVersions } from "@trails-cool/db/schema/journal";
import { parseGpxAsync } from "@trails-cool/gpx";
import { sql } from "drizzle-orm";
export interface RouteInput {
name: string;
@ -19,14 +19,11 @@ export async function createRoute(ownerId: string, input: RouteInput) {
let distance: number | null = null;
let elevationGain: number | null = null;
let elevationLoss: number | null = null;
let geom: SQL | null = null;
if (input.gpx) {
const stats = computeRouteStats(input.gpx);
const stats = await computeRouteStats(input.gpx);
distance = stats.distance;
elevationGain = stats.elevationGain;
elevationLoss = stats.elevationLoss;
geom = stats.geom;
}
await db.insert(routes).values({
@ -39,9 +36,12 @@ export async function createRoute(ownerId: string, input: RouteInput) {
distance,
elevationGain,
elevationLoss,
geom,
});
if (input.gpx) {
await setGeomFromGpx(id, "routes", input.gpx);
}
// Create initial version if GPX provided
if (input.gpx) {
await db.insert(routeVersions).values({
@ -99,11 +99,10 @@ export async function updateRoute(
if (input.gpx) {
updateData.gpx = input.gpx;
const stats = computeRouteStats(input.gpx);
const stats = await computeRouteStats(input.gpx);
updateData.distance = stats.distance;
updateData.elevationGain = stats.elevationGain;
updateData.elevationLoss = stats.elevationLoss;
updateData.geom = stats.geom;
// Get next version number
const existingVersions = await db
@ -127,6 +126,10 @@ export async function updateRoute(
.update(routes)
.set(updateData)
.where(and(eq(routes.id, id), eq(routes.ownerId, ownerId)));
if (input.gpx) {
await setGeomFromGpx(id, "routes", input.gpx);
}
}
export async function deleteRoute(id: string, ownerId: string) {
@ -138,10 +141,9 @@ export async function deleteRoute(id: string, ownerId: string) {
return result.length > 0;
}
function computeRouteStats(gpxString: string) {
async function computeRouteStats(gpxString: string) {
try {
const gpxData = parseGpx(gpxString);
const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]);
const gpxData = await parseGpxAsync(gpxString);
return {
distance: Math.round(
gpxData.elevation.profile.length > 0
@ -150,9 +152,25 @@ function computeRouteStats(gpxString: string) {
),
elevationGain: gpxData.elevation.gain,
elevationLoss: gpxData.elevation.loss,
geom: coords.length >= 2 ? lineStringFromCoords(coords) : null,
};
} catch {
return { distance: null, elevationGain: null, elevationLoss: null, geom: null };
return { distance: null, elevationGain: null, elevationLoss: null };
}
}
async function setGeomFromGpx(id: string, table: "routes" | "activities", gpxString: string) {
try {
const gpxData = await parseGpxAsync(gpxString);
const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]);
if (coords.length < 2) return;
const geojson = JSON.stringify({ type: "LineString", coordinates: coords });
const db = getDb();
await db.execute(
sql`UPDATE ${sql.identifier("journal")}.${sql.identifier(table)} SET geom = ST_GeomFromGeoJSON(${geojson}) WHERE id = ${id}`,
);
} catch (e) {
console.error(`Failed to set geom for ${table}/${id}:`, e);
}
}
export { setGeomFromGpx };