- 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>
5.7 KiB
Context
wahoo-route-push currently POSTs each route version as a fresh route. The push pipeline:
- Looks up
sync_pusheskeyed by(user_id, route_id, route_version, provider). - If a successful row exists for the exact version, returns the existing
remote_id(no-op). - Otherwise calls
wahooProvider.pushRoute()which alwaysPOSTs/v1/routeswithexternal_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_idof 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_pushesrow for(user, route, provider)→ POST/v1/routes(and store the returnedremote_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_logtable later.
Migration Plan
- Add
last_pushed_versioncolumn tosync_pushes(nullable initially). - Backfill
last_pushed_versionfrom the existingroute_versioncolumn on each row. - Collapse rows: per
(user, route, provider)group, keep the highest-version row, delete the rest. - Drop the
route_versioncolumn from the unique constraint, add a unique index on(user_id, route_id, provider). - Drop the
route_versioncolumn itself. - Ship the code change to send PUT instead of POST when a
remote_idexists.
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.