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:
parent
feeca58064
commit
6ed828ef9c
4 changed files with 104 additions and 52 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import {
|
|||
jsonb,
|
||||
customType,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { sql, type SQL } from "drizzle-orm";
|
||||
|
||||
const bytea = customType<{ data: Buffer }>({
|
||||
dataType() {
|
||||
|
|
@ -15,27 +14,12 @@ const bytea = customType<{ data: Buffer }>({
|
|||
},
|
||||
});
|
||||
|
||||
const lineString = customType<{ data: string | SQL }>({
|
||||
const lineString = customType<{ data: string }>({
|
||||
dataType() {
|
||||
return "geometry(LineString, 4326)";
|
||||
},
|
||||
toDriver(value) {
|
||||
return value;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Build a SQL expression to create a PostGIS LineString from coordinate pairs.
|
||||
* Coordinates are [lon, lat] (GeoJSON order).
|
||||
*/
|
||||
export function lineStringFromCoords(coords: [number, number][]): SQL {
|
||||
const geojson = JSON.stringify({
|
||||
type: "LineString",
|
||||
coordinates: coords,
|
||||
});
|
||||
return sql`ST_GeomFromGeoJSON(${geojson})`;
|
||||
}
|
||||
|
||||
export const journalSchema = pgSchema("journal");
|
||||
|
||||
export const users = journalSchema.table("users", {
|
||||
|
|
|
|||
62
packages/gpx/src/geom.test.ts
Normal file
62
packages/gpx/src/geom.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { parseGpxAsync } from "./parse.ts";
|
||||
|
||||
const sampleGpx = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<trk>
|
||||
<trkseg>
|
||||
<trkpt lat="52.52" lon="13.405"><ele>34</ele></trkpt>
|
||||
<trkpt lat="51.05" lon="13.74"><ele>113</ele></trkpt>
|
||||
<trkpt lat="48.137" lon="11.576"><ele>519</ele></trkpt>
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>`;
|
||||
|
||||
describe("GPX to geometry coordinates", () => {
|
||||
it("extracts [lon, lat] coordinates from track points", async () => {
|
||||
const gpxData = await parseGpxAsync(sampleGpx);
|
||||
const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]);
|
||||
|
||||
expect(coords).toHaveLength(3);
|
||||
// GeoJSON order: [lon, lat]
|
||||
expect(coords[0]).toEqual([13.405, 52.52]);
|
||||
expect(coords[1]).toEqual([13.74, 51.05]);
|
||||
expect(coords[2]).toEqual([11.576, 48.137]);
|
||||
});
|
||||
|
||||
it("produces valid GeoJSON LineString", async () => {
|
||||
const gpxData = await parseGpxAsync(sampleGpx);
|
||||
const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]);
|
||||
const geojson = { type: "LineString", coordinates: coords };
|
||||
|
||||
expect(geojson.type).toBe("LineString");
|
||||
expect(geojson.coordinates).toHaveLength(3);
|
||||
// Verify serialization doesn't throw
|
||||
expect(() => JSON.stringify(geojson)).not.toThrow();
|
||||
});
|
||||
|
||||
it("handles empty GPX gracefully", async () => {
|
||||
const emptyGpx = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<trk><trkseg></trkseg></trk>
|
||||
</gpx>`;
|
||||
const gpxData = await parseGpxAsync(emptyGpx);
|
||||
const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]);
|
||||
|
||||
expect(coords).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles single point (insufficient for LineString)", async () => {
|
||||
const singlePointGpx = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<trk><trkseg>
|
||||
<trkpt lat="52.52" lon="13.405"><ele>34</ele></trkpt>
|
||||
</trkseg></trk>
|
||||
</gpx>`;
|
||||
const gpxData = await parseGpxAsync(singlePointGpx);
|
||||
const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]);
|
||||
|
||||
expect(coords).toHaveLength(1);
|
||||
// Caller should check coords.length >= 2 before creating LineString
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue