trails/openspec/changes/atomic-gpx-save/proposal.md
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

2.4 KiB

Why

Route and activity rows can currently be saved with geom IS NULL when the PostGIS geometry write fails after the row insert succeeds — the error is swallowed silently with a console.error. This means missing map thumbnails, broken route-push deduplication, and corrupted spatial queries with no signal to the user or operator. The Planner callback endpoint also accepts GPX from an external source with no validation before writing.

What Changes

  • New gpx-save.server.ts module owns GPX validation and atomic geometry persistence; setGeomFromGpx moves here and becomes internal.
  • validateGpx(gpx) parses the GPX string, checks ≥2 track points and valid coordinate ranges, throws GpxValidationError on failure, and returns the parsed result so callers don't re-parse.
  • createRoute, updateRoute, createActivity, createRouteFromActivity all wrap their DB writes (row insert/update + geom write + version snapshot) in db.transaction() — partial state (row exists, geom NULL) is no longer possible.
  • setGeomFromGpx stops swallowing errors — it throws, causing the transaction to roll back.
  • demo-bot.server.ts migrated from raw inserts + setGeomFromGpx to createRoute / createActivity, eliminating a bypass of the new invariant.
  • activities.server.ts removes inline parseGpxAsync call; uses the ParsedGpx returned by validateGpx for stat extraction instead.
  • ADR recorded: PostGIS geometry writes are always transactional with row writes.

Capabilities

New Capabilities

  • gpx-save: Atomic GPX validation and PostGIS geometry persistence for routes and activities.

Modified Capabilities

(none — no user-visible requirement changes; this is an implementation-layer correctness fix)

Impact

  • apps/journal/app/lib/routes.server.tssetGeomFromGpx export removed; callers updated.
  • apps/journal/app/lib/activities.server.ts — inline parseGpxAsync removed; imports from gpx-save.
  • apps/journal/app/lib/demo-bot.server.ts — raw insert pattern replaced with createRoute / createActivity.
  • apps/journal/app/routes/api.routes.$id.callback.ts — no validation changes needed (validation now inside updateRoute); GPX errors surface as thrown exceptions → callers return 400.
  • @trails-cool/gpx — no changes; parseGpxAsync is still the underlying parser.
  • No API surface changes, no schema changes, no migration required.