Atomic GPX save: validate + persist row + geometry in one transaction

Eliminates the silent-failure pattern where a route/activity row could
be committed with gpx IS NOT NULL but geom IS NULL if the PostGIS write
failed after the row insert.

- New gpx-save.server.ts owns all GPX validation (GpxValidationError,
  validateGpx) and PostGIS geometry writes (writeGeom, tx-aware)
- createRoute, updateRoute, createActivity, createRouteFromActivity all
  wrapped in db.transaction() covering row + geom + version snapshot
- demo-bot uses createRoute/createActivity instead of raw inserts;
  errors propagate loudly
- Callback endpoint returns 400 for GpxValidationError instead of 401
- ADR-0006 documents the invariant for future explorers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-05-10 15:02:56 +02:00
parent 8ae58a2d26
commit 78b8b8f55f
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
13 changed files with 624 additions and 182 deletions

View file

@ -3,8 +3,8 @@ import { eq, desc, and, sql } from "drizzle-orm";
import { getDb } from "./db.ts";
import { activities, routes, syncImports, users, follows } from "@trails-cool/db/schema/journal";
import type { Visibility } from "@trails-cool/db/schema/journal";
import { parseGpxAsync } from "@trails-cool/gpx";
import { setGeomFromGpx } from "./routes.server.ts";
import { validateGpx, writeGeom } from "./gpx-save.server.ts";
import type { GpxData } from "./gpx-save.server.ts";
export interface ActivityInput {
name: string;
@ -15,6 +15,7 @@ export interface ActivityInput {
duration?: number | null;
startedAt?: Date | null;
visibility?: Visibility;
synthetic?: boolean;
}
export async function updateActivityVisibility(
@ -46,44 +47,45 @@ export async function createActivity(ownerId: string, input: ActivityInput) {
const db = getDb();
const id = randomUUID();
let parsed: GpxData | null = null;
let distance: number | null = input.distance ?? null;
let elevationGain: number | null = null;
let elevationLoss: number | null = null;
let startedAt: Date | null = input.startedAt ?? null;
const duration: number | null = input.duration ?? null;
if (input.gpx) {
try {
const gpxData = await parseGpxAsync(input.gpx);
distance = gpxData.distance || distance;
elevationGain = gpxData.elevation.gain;
elevationLoss = gpxData.elevation.loss;
if (!startedAt && gpxData.tracks[0]?.[0]?.time) {
startedAt = new Date(gpxData.tracks[0][0].time);
}
} catch {
// Continue without stats if GPX parsing fails
if (input.gpx) {
parsed = await validateGpx(input.gpx);
distance = parsed.distance || distance;
elevationGain = parsed.elevation.gain;
elevationLoss = parsed.elevation.loss;
if (!startedAt && parsed.tracks[0]?.[0]?.time) {
startedAt = new Date(parsed.tracks[0][0].time);
}
}
await db.insert(activities).values({
id,
ownerId,
routeId: input.routeId ?? null,
name: input.name,
description: input.description ?? "",
gpx: input.gpx,
distance,
duration,
elevationGain,
elevationLoss,
startedAt,
...(input.visibility ? { visibility: input.visibility } : {}),
});
await db.transaction(async (tx) => {
await tx.insert(activities).values({
id,
ownerId,
routeId: input.routeId ?? null,
name: input.name,
description: input.description ?? "",
gpx: input.gpx,
distance,
duration,
elevationGain,
elevationLoss,
startedAt,
...(input.visibility ? { visibility: input.visibility } : {}),
...(input.synthetic ? { synthetic: true } : {}),
});
if (input.gpx) {
await setGeomFromGpx(id, "activities", input.gpx);
}
if (input.gpx && parsed) {
const coords = parsed.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]);
await writeGeom(tx, id, "activities", coords);
}
});
// Public activities at creation also fan out (matches the
// updateActivityVisibility path for the case where visibility is set
@ -236,26 +238,27 @@ export async function createRouteFromActivity(activityId: string, ownerId: strin
const [activity] = await db.select().from(activities).where(eq(activities.id, activityId));
if (!activity?.gpx) return null;
const parsed = await validateGpx(activity.gpx);
const coords = parsed.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]);
const routeId = randomUUID();
await db.insert(routes).values({
id: routeId,
ownerId,
name: `Route from: ${activity.name}`,
description: `Created from activity "${activity.name}"`,
gpx: activity.gpx,
distance: activity.distance,
elevationGain: activity.elevationGain,
elevationLoss: activity.elevationLoss,
await db.transaction(async (tx) => {
await tx.insert(routes).values({
id: routeId,
ownerId,
name: `Route from: ${activity.name}`,
description: `Created from activity "${activity.name}"`,
gpx: activity.gpx,
distance: activity.distance,
elevationGain: activity.elevationGain,
elevationLoss: activity.elevationLoss,
});
await writeGeom(tx, routeId, "routes", coords);
await tx.update(activities).set({ routeId }).where(eq(activities.id, activityId));
});
await setGeomFromGpx(routeId, "routes", activity.gpx);
// Link the activity to the new route
await db
.update(activities)
.set({ routeId })
.where(eq(activities.id, activityId));
return routeId;
}

View file

@ -4,9 +4,10 @@ import { z } from "zod";
import { and, eq, lt, sql } from "drizzle-orm";
import { getDb } from "./db.ts";
import { activities, routes, users } from "@trails-cool/db/schema/journal";
import { setGeomFromGpx } from "./routes.server.ts";
import { createRoute } from "./routes.server.ts";
import { createActivity } from "./activities.server.ts";
import { validateGpx } from "./gpx-save.server.ts";
import { TERMS_VERSION } from "./legal.ts";
import { parseGpxAsync } from "@trails-cool/gpx";
import { logger } from "./logger.server.ts";
import {
demoBotSyntheticActivitiesTotal,
@ -648,59 +649,40 @@ export async function generateOneWalk(
const name = templateName(now, locale, persona);
const description = templateDescription(now, locale, persona);
let distance: number;
let elevationGain: number;
let elevationLoss: number;
try {
const parsed = await parseGpxAsync(result.gpx);
distance = parsed.distance;
elevationGain = parsed.elevation.gain;
elevationLoss = parsed.elevation.loss;
} catch {
return null;
}
const parsed = await validateGpx(result.gpx);
const distance = parsed.distance;
const elevationGain = parsed.elevation.gain;
const elevationLoss = parsed.elevation.loss;
if (!distance || distance < 500) return null;
const db = getDb();
const routeId = randomUUID();
const activityId = randomUUID();
await db.insert(routes).values({
id: routeId,
ownerId,
name,
description,
gpx: result.gpx,
routingProfile: "trekking",
distance,
elevationGain,
elevationLoss,
visibility: "public",
synthetic: true,
});
await setGeomFromGpx(routeId, "routes", result.gpx);
const walkingMetersPerSecond = 4.5 * 1000 / 3600; // ~4.5 km/h
const jitter = 0.85 + Math.random() * 0.3; // ±15 %
const durationSeconds = Math.round((distance / walkingMetersPerSecond) * jitter);
await db.insert(activities).values({
id: activityId,
ownerId,
routeId,
const routeId = await createRoute(ownerId, {
name,
description,
gpx: result.gpx,
startedAt: now,
duration: durationSeconds,
routingProfile: "trekking",
visibility: "public",
synthetic: true,
distance,
elevationGain,
elevationLoss,
});
await createActivity(ownerId, {
name,
description,
gpx: result.gpx,
routeId,
startedAt: now,
duration: durationSeconds,
distance,
visibility: "public",
synthetic: true,
});
await setGeomFromGpx(activityId, "activities", result.gpx);
return routeId;
}

View file

@ -0,0 +1,86 @@
import { describe, it, expect } from "vitest";
import { validateGpx, GpxValidationError } from "./gpx-save.server.ts";
const VALID_GPX = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
<trk>
<trkseg>
<trkpt lat="47.0" lon="8.0"><ele>500</ele></trkpt>
<trkpt lat="47.1" lon="8.1"><ele>520</ele></trkpt>
<trkpt lat="47.2" lon="8.2"><ele>510</ele></trkpt>
</trkseg>
</trk>
</gpx>`;
const ONE_POINT_GPX = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
<trk>
<trkseg>
<trkpt lat="47.0" lon="8.0"><ele>500</ele></trkpt>
</trkseg>
</trk>
</gpx>`;
const EMPTY_GPX = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
<trk><trkseg></trkseg></trk>
</gpx>`;
const OUT_OF_RANGE_LAT_GPX = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
<trk>
<trkseg>
<trkpt lat="91.0" lon="8.0"></trkpt>
<trkpt lat="47.0" lon="8.0"></trkpt>
</trkseg>
</trk>
</gpx>`;
const OUT_OF_RANGE_LON_GPX = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
<trk>
<trkseg>
<trkpt lat="47.0" lon="181.0"></trkpt>
<trkpt lat="47.1" lon="8.0"></trkpt>
</trkseg>
</trk>
</gpx>`;
describe("validateGpx", () => {
it("returns parsed GpxData for valid GPX", async () => {
const result = await validateGpx(VALID_GPX);
expect(result.tracks.flat().length).toBe(3);
expect(result.tracks[0]![0]!.lat).toBeCloseTo(47.0);
});
it("throws GpxValidationError for GPX with fewer than 2 track points", async () => {
await expect(validateGpx(ONE_POINT_GPX)).rejects.toThrow(GpxValidationError);
await expect(validateGpx(ONE_POINT_GPX)).rejects.toThrow("at least 2 track points");
});
it("throws GpxValidationError for GPX with zero track points", async () => {
await expect(validateGpx(EMPTY_GPX)).rejects.toThrow(GpxValidationError);
await expect(validateGpx(EMPTY_GPX)).rejects.toThrow("at least 2 track points");
});
it("throws GpxValidationError for out-of-range latitude", async () => {
await expect(validateGpx(OUT_OF_RANGE_LAT_GPX)).rejects.toThrow(GpxValidationError);
await expect(validateGpx(OUT_OF_RANGE_LAT_GPX)).rejects.toThrow("out-of-range coordinates");
});
it("throws GpxValidationError for out-of-range longitude", async () => {
await expect(validateGpx(OUT_OF_RANGE_LON_GPX)).rejects.toThrow(GpxValidationError);
await expect(validateGpx(OUT_OF_RANGE_LON_GPX)).rejects.toThrow("out-of-range coordinates");
});
it("throws GpxValidationError for unparseable XML", async () => {
await expect(validateGpx("not xml at all")).rejects.toThrow(GpxValidationError);
await expect(validateGpx("<broken<xml")).rejects.toThrow(GpxValidationError);
});
it("GpxValidationError has the correct name", async () => {
const err = await validateGpx(EMPTY_GPX).catch((e) => e);
expect(err).toBeInstanceOf(GpxValidationError);
expect(err.name).toBe("GpxValidationError");
});
});

View file

@ -0,0 +1,66 @@
import { sql } from "drizzle-orm";
import type { Database } from "@trails-cool/db";
import { parseGpxAsync } from "@trails-cool/gpx";
import type { GpxData } from "@trails-cool/gpx";
// Re-export so callers only need one import.
export type { GpxData };
export class GpxValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "GpxValidationError";
}
}
/**
* Parse and validate a GPX string. Returns the parsed data so callers
* don't need to parse again for stat extraction. Throws GpxValidationError
* when the GPX is malformed, has fewer than 2 track points, or contains
* out-of-range coordinates.
*/
export async function validateGpx(gpx: string): Promise<GpxData> {
let parsed: GpxData;
try {
parsed = await parseGpxAsync(gpx);
} catch (e) {
throw new GpxValidationError(`GPX parse failed: ${(e as Error).message}`);
}
const points = parsed.tracks.flat();
if (points.length < 2) {
throw new GpxValidationError(
`GPX must contain at least 2 track points (got ${points.length})`,
);
}
for (const p of points) {
if (p.lat < -90 || p.lat > 90 || p.lon < -180 || p.lon > 180) {
throw new GpxValidationError(
`GPX contains out-of-range coordinates: lat=${p.lat}, lon=${p.lon}`,
);
}
}
return parsed;
}
type Tx = Parameters<Parameters<Database["transaction"]>[0]>[0];
/**
* Write a PostGIS LineString geometry from already-validated track points.
* Must be called inside a db.transaction() the tx client is required so
* the geometry write participates in the enclosing transaction.
* Throws on failure; callers must NOT swallow the error.
*/
export async function writeGeom(
tx: Tx,
id: string,
table: "routes" | "activities",
coords: Array<[number, number]>,
): Promise<void> {
const geojson = JSON.stringify({ type: "LineString", coordinates: coords });
await tx.execute(
sql`UPDATE ${sql.identifier("journal")}.${sql.identifier(table)} SET geom = ST_GeomFromGeoJSON(${geojson}) WHERE id = ${id}`,
);
}

View file

@ -3,8 +3,9 @@ import { eq, desc, and } from "drizzle-orm";
import { getDb } from "./db.ts";
import { routes, routeVersions } from "@trails-cool/db/schema/journal";
import type { Visibility } from "@trails-cool/db/schema/journal";
import { parseGpxAsync } from "@trails-cool/gpx";
import { sql } from "drizzle-orm";
import { validateGpx, writeGeom } from "./gpx-save.server.ts";
import type { GpxData } from "./gpx-save.server.ts";
export interface RouteInput {
name: string;
@ -12,52 +13,65 @@ export interface RouteInput {
gpx?: string;
routingProfile?: string;
visibility?: Visibility;
// Pre-computed stats — when provided, skip re-parsing (used by demo-bot)
distance?: number | null;
elevationGain?: number | null;
elevationLoss?: number | null;
dayBreaks?: number[];
synthetic?: boolean;
}
export async function createRoute(ownerId: string, input: RouteInput) {
const db = getDb();
const id = randomUUID();
let distance: number | null = null;
let elevationGain: number | null = null;
let elevationLoss: number | null = null;
let dayBreaks: number[] = [];
let parsed: GpxData | null = null;
let distance: number | null = input.distance ?? null;
let elevationGain: number | null = input.elevationGain ?? null;
let elevationLoss: number | null = input.elevationLoss ?? null;
let dayBreaks: number[] = input.dayBreaks ?? [];
if (input.gpx) {
const stats = await computeRouteStats(input.gpx);
distance = stats.distance;
elevationGain = stats.elevationGain;
elevationLoss = stats.elevationLoss;
dayBreaks = stats.dayBreaks;
parsed = await validateGpx(input.gpx);
// Only compute stats from GPX if not pre-supplied by caller
if (input.distance === undefined) {
const stats = computeRouteStats(parsed);
distance = stats.distance;
elevationGain = stats.elevationGain;
elevationLoss = stats.elevationLoss;
dayBreaks = stats.dayBreaks;
}
}
await db.insert(routes).values({
id,
ownerId,
name: input.name,
description: input.description ?? "",
gpx: input.gpx,
routingProfile: input.routingProfile,
distance,
elevationGain,
elevationLoss,
dayBreaks,
});
if (input.gpx) {
await setGeomFromGpx(id, "routes", input.gpx);
}
// Create initial version if GPX provided
if (input.gpx) {
await db.insert(routeVersions).values({
id: randomUUID(),
routeId: id,
version: 1,
await db.transaction(async (tx) => {
await tx.insert(routes).values({
id,
ownerId,
name: input.name,
description: input.description ?? "",
gpx: input.gpx,
createdBy: ownerId,
changeDescription: "Initial version",
routingProfile: input.routingProfile,
distance,
elevationGain,
elevationLoss,
dayBreaks,
...(input.synthetic ? { synthetic: true } : {}),
});
}
if (input.gpx && parsed) {
const coords = parsed.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]);
await writeGeom(tx, id, "routes", coords);
await tx.insert(routeVersions).values({
id: randomUUID(),
routeId: id,
version: 1,
gpx: input.gpx,
createdBy: ownerId,
changeDescription: "Initial version",
});
}
});
return id;
}
@ -92,7 +106,6 @@ export async function listRoutes(ownerId: string) {
.where(eq(routes.ownerId, ownerId))
.orderBy(desc(routes.updatedAt));
// Batch-fetch simplified GeoJSON for list thumbnails
const ids = rows.map((r) => r.id);
const geojsonMap = ids.length > 0 ? await getSimplifiedGeojsonBatch(ids) : new Map();
return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null }));
@ -122,14 +135,19 @@ export async function updateRoute(
) {
const db = getDb();
let parsed: GpxData | null = null;
if (input.gpx) {
parsed = await validateGpx(input.gpx);
}
const updateData: Record<string, unknown> = { updatedAt: new Date() };
if (input.name !== undefined) updateData.name = input.name;
if (input.description !== undefined) updateData.description = input.description;
if (input.visibility !== undefined) updateData.visibility = input.visibility;
if (input.gpx) {
if (input.gpx && parsed) {
const stats = computeRouteStats(parsed);
updateData.gpx = input.gpx;
const stats = await computeRouteStats(input.gpx);
updateData.distance = stats.distance;
updateData.elevationGain = stats.elevationGain;
updateData.elevationLoss = stats.elevationLoss;
@ -137,33 +155,35 @@ export async function updateRoute(
if (stats.description && input.description === undefined) {
updateData.description = stats.description;
}
// Get next version number
const existingVersions = await db
.select()
.from(routeVersions)
.where(eq(routeVersions.routeId, id))
.orderBy(desc(routeVersions.version));
const nextVersion = (existingVersions[0]?.version ?? 0) + 1;
await db.insert(routeVersions).values({
id: randomUUID(),
routeId: id,
version: nextVersion,
gpx: input.gpx,
createdBy: ownerId,
});
}
await db
.update(routes)
.set(updateData)
.where(and(eq(routes.id, id), eq(routes.ownerId, ownerId)));
await db.transaction(async (tx) => {
await tx
.update(routes)
.set(updateData)
.where(and(eq(routes.id, id), eq(routes.ownerId, ownerId)));
if (input.gpx) {
await setGeomFromGpx(id, "routes", input.gpx);
}
if (input.gpx && parsed) {
const coords = parsed.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]);
await writeGeom(tx, id, "routes", coords);
const existingVersions = await tx
.select()
.from(routeVersions)
.where(eq(routeVersions.routeId, id))
.orderBy(desc(routeVersions.version));
const nextVersion = (existingVersions[0]?.version ?? 0) + 1;
await tx.insert(routeVersions).values({
id: randomUUID(),
routeId: id,
version: nextVersion,
gpx: input.gpx,
createdBy: ownerId,
});
}
});
}
export async function deleteRoute(id: string, ownerId: string) {
@ -175,41 +195,19 @@ export async function deleteRoute(id: string, ownerId: string) {
return result.length > 0;
}
async function computeRouteStats(gpxString: string) {
try {
const gpxData = await parseGpxAsync(gpxString);
const dayBreaks = gpxData.waypoints
.map((w, i) => (w.isDayBreak ? i : -1))
.filter((i) => i >= 0);
return {
distance: gpxData.distance,
elevationGain: gpxData.elevation.gain,
elevationLoss: gpxData.elevation.loss,
dayBreaks,
description: gpxData.description,
};
} catch {
return { distance: null, elevationGain: null, elevationLoss: null, dayBreaks: [] as number[], description: undefined };
}
function computeRouteStats(gpxData: GpxData) {
const dayBreaks = gpxData.waypoints
.map((w, i) => (w.isDayBreak ? i : -1))
.filter((i) => i >= 0);
return {
distance: gpxData.distance,
elevationGain: gpxData.elevation.gain,
elevationLoss: gpxData.elevation.loss,
dayBreaks,
description: gpxData.description,
};
}
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 };
async function getGeojson(table: "routes" | "activities", id: string): Promise<string | null> {
try {
const db = getDb();

View file

@ -2,6 +2,7 @@ import { data } from "react-router";
import type { Route } from "./+types/api.routes.$id.callback";
import { verifyRouteToken } from "~/lib/jwt.server";
import { updateRoute, getRoute } from "~/lib/routes.server";
import { GpxValidationError } from "~/lib/gpx-save.server";
const PLANNER_ORIGIN = process.env.PLANNER_URL ?? "http://localhost:3001";
@ -65,6 +66,9 @@ export async function action({ params, request }: Route.ActionArgs) {
return data({ success: true, routeId: params.id }, { headers: corsHeaders() });
} catch (e) {
if (e instanceof GpxValidationError) {
return data({ error: e.message }, { status: 400, headers: corsHeaders() });
}
return data({ error: (e as Error).message }, { status: 401, headers: corsHeaders() });
}
}