trails/apps/journal/app/lib/gpx-save.server.ts
Ullrich Schäfer 78b8b8f55f
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>
2026-05-10 15:02:56 +02:00

66 lines
2 KiB
TypeScript

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}`,
);
}