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>
2.5 KiB
ADR-0006: Atomic GPX Save via gpx-save Module
Status: Accepted
Date: 2026-05-10
Context
Routes and activities store GPX text in a gpx column and a derived PostGIS LineString in a geom column. Originally, the row insert and the ST_GeomFromGeoJSON update were separate statements issued outside any transaction. If the geometry write failed (e.g. PostGIS unavailable, malformed coordinates), the row would be committed with geom IS NULL and no error surfaced. This silent failure made the geom column unreliable and was invisible to callers.
Additionally, GPX validation (parse, minimum track points, coordinate range check) was either absent or scattered across callers, with some paths swallowing parse errors and continuing without geometry.
Decision
-
Single module owns geometry persistence.
apps/journal/app/lib/gpx-save.server.tsis the sole place that validates GPX and writes PostGIS geometry. No other module issues rawUPDATE … SET geom = …statements. -
validateGpx(gpx) → Promise<GpxData>parses the GPX string, asserts ≥ 2 track points, and checks all coordinates are within valid ranges (lat −90..90, lon −180..180). It throwsGpxValidationErroron any failure. It returns the parsed result so callers don't re-parse for stat extraction. -
writeGeom(tx, id, table, coords)accepts a Drizzle transaction client and writes the PostGIS geometry inside that transaction. It throws on failure — no try/catch anywhere in the call chain. -
Every save that includes GPX is wrapped in
db.transaction()covering: the row insert/update, thewriteGeomcall, and any version snapshot insert. A PostGIS failure rolls back the entire transaction — a row withgpx IS NOT NULLandgeom IS NULLcannot be produced through the normal save path. -
Fail loudly everywhere. The demo-bot and all callers surface errors rather than swallowing them. The demo-bot uses
createRoute/createActivityrather than raw inserts.
Consequences
GpxValidationErrorsurfaces to API callers. The Planner callback endpoint (api.routes.$id.callback.ts) catches it and returns 400 so the Planner receives a typed error.- The
syntheticflag is threaded throughRouteInputandActivityInputso the demo-bot can mark rows without bypassing the shared save functions. - Any future module that needs to persist geometry must go through
gpx-save.server.ts. Do not re-introduce inlineUPDATE … SET geom = …statements elsewhere.