trails/openspec/changes/wahoo-route-update/design.md
Ullrich Schäfer 6a752e4933
Add openspec changes for Wahoo production cutover and route updates
- wahoo-production-cutover: ops checklist for moving the Wahoo Cloud
  API integration from sandbox to production tier (new app
  registration, logo upload, forced reauthorization).
- wahoo-route-update: switch the route push pipeline from POST-per-
  version to POST-then-PUT so re-pushing an edited route updates the
  existing Wahoo route instead of creating a duplicate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 11:37:42 +02:00

5.7 KiB

Context

wahoo-route-push currently POSTs each route version as a fresh route. The push pipeline:

  1. Looks up sync_pushes keyed by (user_id, route_id, route_version, provider).
  2. If a successful row exists for the exact version, returns the existing remote_id (no-op).
  3. Otherwise calls wahooProvider.pushRoute() which always POSTs /v1/routes with external_id = route:<routeId>:v<version>.

Wahoo confirmed in their docs that POST with a duplicate external_id does not update — only PUT /v1/routes/:id does. So even though our external_id is "stable per version", it does not help us update across versions; the second push of the same version is the only thing it dedupes (which is also already covered by our local sync_pushes check).

The user-visible problem is exactly the case the current spec doesn't address: re-pushing a new version. Today that creates a duplicate Wahoo route. We need to detect "we already have a remote_id for this route" and PUT to it.

Goals / Non-Goals

Goals:

  • After this change, editing a route locally and re-pushing it updates the Wahoo route in place: same Wahoo URL, same id in the Wahoo app, just refreshed track and metadata.
  • Idempotency for retrying a failed push of the same version still works.
  • Existing successfully-pushed routes (one row per old version on our side) collapse cleanly to one row per logical route without losing the remote_id of the most recent push.

Non-Goals:

  • Not changing how the user triggers a push (button stays the same).
  • Not deleting old Wahoo routes that were already duplicated by previous behavior — users can clean those up in the Wahoo app. We just stop creating new duplicates.
  • Not generalizing this to other providers — the sync framework allows per-provider update semantics, but only Wahoo needs it now.

Decisions

Switch idempotency key from (user, route, version, provider) to (user, route, provider). A logical Wahoo route is per-route, not per-version. The version that's currently on Wahoo is a property of that row (last_pushed_version), not a key. This matches the new "PUT or POST" branching we need.

Send external_id = route:<routeId> (drop the version suffix). The external_id is meant to be a stable third-party id; "the route" is what's stable, not "this exact edit of the route". This also makes the data correct if a future Wahoo behavior change starts respecting external_id for upserts.

POST on first push, PUT on subsequent pushes. Decision rule:

  • If there's no sync_pushes row for (user, route, provider) → POST /v1/routes (and store the returned remote_id).
  • If a row exists with a non-null remote_id → PUT /v1/routes/<remote_id>.
  • If a row exists with a null remote_id (previous push failed) → POST again, just like a fresh push.

If a PUT returns 404 (the user deleted the Wahoo route from their side), fall back to POST and overwrite remote_id. This keeps the system self-healing.

Version handling on the row. The single sync_pushes row tracks last_pushed_version (integer) and pushed_at (timestamp). The route detail page compares last_pushed_version to the local routes.current_version:

  • equal → "On Wahoo (vN, sent at …)"
  • local newer → "On Wahoo (vN-1) · Send updated version"
  • local older (shouldn't happen, but…) → fall back to the equal case.

Migration: collapse existing rows. For each (user_id, route_id, provider='wahoo') group, keep only the row with the highest route_version and copy that version into the new last_pushed_version column. Drop the rest. Failed rows for already-pushed routes are dropped entirely (they're not reachable for retry under the new key anyway). No external Wahoo state changes — the remote_ids on the kept rows are still the live Wahoo route ids.

Risks / Trade-offs

  • [Risk] Wahoo's PUT requires fields we currently send on POST (e.g., file, name, distance) and rejects partials silently. Mitigation: send the same body shape we send on POST. Test against a real Wahoo account before flipping the default.
  • [Risk] PUT against a deleted Wahoo route returns 404 and confuses the user. Mitigation: catch 404, fall back to POST automatically, log the recovery. Already in the decision rule.
  • [Risk] Schema migration deletes a sync_pushes row a user might want to retry. Mitigation: only collapse rows where the highest-versioned row was successful. If the highest version's row failed, keep that row (it's still the canonical one under the new key) and drop only strictly older rows.
  • [Trade-off] We lose per-version visibility ("which of my edits made it to Wahoo and when"). Acceptable: the route detail page is already version-aware locally, and Wahoo doesn't store our versions, only its own copy. If we ever want a full push history, add a sync_push_log table later.

Migration Plan

  1. Add last_pushed_version column to sync_pushes (nullable initially).
  2. Backfill last_pushed_version from the existing route_version column on each row.
  3. Collapse rows: per (user, route, provider) group, keep the highest-version row, delete the rest.
  4. Drop the route_version column from the unique constraint, add a unique index on (user_id, route_id, provider).
  5. Drop the route_version column itself.
  6. Ship the code change to send PUT instead of POST when a remote_id exists.

This is one Drizzle migration. The old code path (per-version rows) and new code path (per-route rows) cannot coexist on the same DB, so we ship migration + code change together.

Open Questions

  • Does Wahoo's PUT response include the same fields as POST (id, etc.) so we can keep the same parsing path? Likely yes — sanity-check during implementation.