Reshape the sync-providers seam before Komoot (web-login) and Apple Health (device) adapters land. Captures the decisions in three ADRs, seeds CONTEXT.md with Connected Services vocabulary, and proposes the OpenSpec change covering schema rename + ConnectedServiceManager + capability seams (Importer / RoutePusher / WebhookReceiver). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
12 KiB
Context
apps/journal/app/lib/sync/ today uses a unified SyncProvider interface (types.ts:88-118) with one adapter (Wahoo). The interface bakes OAuth into its shape — getAuthUrl, exchangeCode, refreshToken, parseWebhook, plus optional pushRoute? / updateRoute? / revoke?. Token-refresh logic lives inline in pushes.server.ts (~lines 145-170). The CRUD layer (connections.server.ts) is a thin wrapper over the sync_connections table.
Two new providers are imminent and each violates the OAuth assumption: Komoot (no official API; we authenticate via the website's login form and reuse session cookies — credential_kind = web-login) and Apple Health (no remote credential at all; data arrives via authenticated mobile API uploads — credential_kind = device). Hand-merging either into the current shape produces NOPs (refreshToken, parseWebhook) and OAuth-shaped queries.
Three architectural decisions have already been recorded:
- ADR-0001: credential-kind discriminator + JSONB blob (not OAuth-only schema, not per-kind tables).
- ADR-0002: capability seams (
Importer/RoutePusher/WebhookReceiver), not a unifiedSyncProvider. - ADR-0003:
RoutePusherinterface shape is(service, route) → {remoteId, version}; Wahoo workarounds stay inside the adapter.
Domain vocabulary is in CONTEXT.md (Connected Services section).
Goals / Non-Goals
Goals:
- Reshape the
sync_connectionsstorage and theSyncProviderinterface before Komoot or Apple Health code lands, so neither has to distort the architecture. - Centralize credential lifecycle (refresh / mark-needs-relink) in one module so future providers don't reinvent it.
- Make capability seams (
Importer,RoutePusher,WebhookReceiver) testable independently with a fakeConnectedServiceManager. - Commit to a
RoutePushershape with one adapter today, so Coros / Garmin / Strava push won't reshape it. - Carry all three credential kinds (
oauth | web-login | device) in the schema enum from day one to avoid follow-up migrations.
Non-Goals:
- Adding the Komoot importer or
web-loginCredentialAdapter(lands with the amendedkomoot-importchange). - Adding the Apple Health adapter or
deviceCredentialAdapter(lands withmobile-app). - Changing user-visible behaviour (settings page, OAuth flows, import / push semantics, webhook handling, idempotency rules).
- Touching
sync_importsorsync_pushesschema. Onlysync_connectionsis renamed. - Defining cross-provider behaviour beyond credential lifecycle.
ImporterandWebhookReceiverare typed seams but their per-provider mechanics stay heterogeneous.
Decisions
Decision 1: One connected_services table, polymorphic credentials
sync_connections is renamed to connected_services. New columns:
credential_kind text NOT NULL— discriminator:oauth | web-login | device.credentials jsonb NOT NULL— shape determined bycredential_kind. Foroauth:{access_token, refresh_token, expires_at}. Forweb-login(later):{email, encrypted_password, session_jar}. Fordevice(later):{}.
Top-level columns kept: user_id, provider, provider_user_id (nullable), granted_scopes (nullable text[], populated only for oauth), created_at. Old columns access_token / refresh_token / expires_at are dropped after backfill.
Why JSONB + discriminator instead of nullable OAuth columns or per-kind tables: see ADR-0001. Short version: nullable OAuth makes Komoot and Apple Health leave half the columns NULL; per-kind tables fragment the user-facing "Connected Services" list and complicate the registry.
granted_scopes stays a top-level column because feature gates (if (!granted_scopes.includes('routes_write')) re-auth) read it on every push. Promoting it out of JSONB keeps gate queries cheap and untyped JSONB indexing unnecessary.
Decision 2: Capability seams, not a unified provider
SyncProvider is replaced by three interfaces, each implementable independently:
interface Importer {
listImportable(service, page): Promise<ImportableList>;
importOne(service, id): Promise<ImportResult>;
}
interface RoutePusher {
pushRoute(service, route): Promise<{remoteId, version}>;
}
interface WebhookReceiver {
parseWebhook(body, headers): WebhookEvent | null;
handle(event): Promise<void>;
}
A Provider is a manifest co-located with its code:
// providers/wahoo/manifest.ts
export const wahoo: ProviderManifest = {
id: 'wahoo',
credentialKind: 'oauth',
oauth: { /* getAuthUrl, exchangeCode, scopes */ },
importer: wahooImporter,
pusher: wahooPusher,
webhookReceiver: wahooWebhook,
};
providers/registry.ts imports each manifest. Adding a provider is one new directory plus one import line. Per ADR-0002, there is no pan-provider behaviour interface — the manager owns credential lifecycle, capability adapters own everything else.
Why this over a unified interface with optional methods: Wahoo's current interface has three optional methods already (pushRoute? / updateRoute? / revoke?); the codebase has been informally drifting toward capability seams. Formalizing it makes "does Komoot push? does Apple Health webhook?" a manifest-level fact rather than a runtime if (provider.pushRoute) check.
Decision 3: ConnectedServiceManager owns credential lifecycle
class ConnectedServiceManager {
link(userId, providerId, kind, credentials): Promise<ConnectedService>;
unlink(serviceId): Promise<void>;
withFreshCredentials<T>(serviceId, fn: (creds) => Promise<T>): Promise<T>;
markNeedsRelink(serviceId, reason): Promise<void>;
}
withFreshCredentials is the chokepoint. It loads the row, checks if the credential is expired (per-kind logic via CredentialAdapter), refreshes if needed, calls fn(credentials), and on credential failure flips status = needs_relink. Importers, pushers, and webhook handlers always go through this — they never read the credentials JSONB directly.
Why centralize: today's refresh logic lives inline in pushes.server.ts (~lines 145-170). Komoot's relogin and Apple Health's noop need the same chokepoint. One module = one place where credential bugs and races are caught.
Decision 4: oauth CredentialAdapter ships now; web-login and device are stubs
interface CredentialAdapter {
refresh(credentials): Promise<Credentials | NeedsRelink>;
revoke?(credentials): Promise<void>;
}
The oauth adapter ships with this change and contains the existing Wahoo-style refresh-token flow plus optional revoke (for providers that support DELETE /v1/permissions). The web-login and device adapters are not implemented in this change — they land with their respective consumer changes. The discriminator enum carries all three values from day one so the consumer changes don't need a migration.
Decision 5: RoutePusher shape commits to (service, route) → result
Per ADR-0003, the seam is:
interface RoutePusher {
pushRoute(service: ConnectedService, route: Route): Promise<{remoteId: string, version: number}>;
}
Wahoo's adapter handles internally:
- FIT-Course conversion via
gpxToFitCourse. - The
external_id = route:<id>convention. - The PUT-vs-POST decision based on
sync_pushes.remote_id. - The PUT→POST-on-404 fallback when the user deleted the Wahoo route on their side.
- The
data:application/vnd.fit;base64,...body encoding.
Idempotency tracking via sync_pushes (user_id, route_id, provider) → remote_id, last_pushed_version is the shared contract — every adapter must read/write this table — but how it's used (PUT vs POST, fallback) is adapter-internal.
Decision 6: Spec deltas are renames + capability-seam terminology
Three spec files are touched:
connected-services/spec.md: renamesync_connections→connected_serviceseverywhere; add a requirement codifying the capability-seam model (replacing the implicit "OAuth tokens stored in" framing).wahoo-import/spec.md: rename. The "Provider-agnostic sync framework" requirement is rewritten to reference capability seams + manifest registration instead of "implement theSyncProviderinterface in a single file."wahoo-route-push/spec.md: rename only. User-visible behaviour and idempotency rules are unchanged.
Risks / Trade-offs
-
[Risk] Migration runs on production
connected_servicesrows (sandbox era — small, but non-zero) — botched backfill could leave Wahoo connections unusable until manual repair. → Mitigation: migration is a single transaction; backfill is purely column-shape (no value transformation beyond moving three columns into a JSON object); deploy with a dry-run on staging first; rollback = re-run the inverse migration restoring columns from JSON. -
[Risk] OpenSpec change
komoot-importalready drafts ajournal.integrationstable — any in-progress implementation against that drafted spec becomes wasted work. → Mitigation: this change documents the supersession explicitly. Thekomoot-importdesign.md will be amended in a separate, small follow-up before any Komoot code lands; if Komoot work has already started against the old shape, it is small and isolated (one importer file, no shipped DB). -
[Trade-off] Carrying
web-loginanddevicein thecredential_kindenum without their adapters means an empty enum branch ships. The alternative (add the values when their adapters land) costs an extra migration each. → Accepted: enum-only forward declaration is cheap; migration churn is not. -
[Trade-off]
RoutePusheris a single-adapter seam today. Per ADR-0003 we commit to its shape now to avoid a reshape when Coros/Garmin/Strava push lands. Accepted because the shape is small ((service, route) → {remoteId, version}) and Wahoo specifics already live behind it. -
[Risk] Test reorganization (existing
wahoo.test.tsandpushes.server.test.tssplit intoimporter.test.ts/pusher.test.ts/webhook.test.ts/manager.test.ts) loses git blame continuity. → Mitigation: do the file moves withgit mvin the same commit as the code split; reviewers can use--followto trace history.
Migration Plan
Single deployable PR ("spec apply") containing:
- DB migration (Drizzle): rename
sync_connections→connected_services; addcredential_kind text NOT NULL(default'oauth'only for the duration of backfill, then drop default); addcredentials jsonb NOT NULL; addstatus text NOT NULL DEFAULT 'active'; backfillcredentials = jsonb_build_object('access_token', access_token, 'refresh_token', refresh_token, 'expires_at', expires_at); dropaccess_token / refresh_token / expires_at. - New module
apps/journal/app/lib/connected-services/(manager + oauth credential adapter + registry + types). - Wahoo split:
apps/journal/app/lib/sync/providers/wahoo.tsis moved toapps/journal/app/lib/connected-services/providers/wahoo/{importer.ts, pusher.ts, webhook.ts, manifest.ts, oauth.ts}. The oldsync/directory is deleted. - Caller updates: routes (
api.sync.connect.*,api.sync.disconnect.*,api.sync.webhook.wahoo), background jobs, and the route push action update their imports to go through the manager + registry. - Test reorganization:
wahoo.test.ts(285 lines) andpushes.server.test.ts(309 lines) split into capability-aligned test files; newmanager.test.tscovers refresh / needs_relink / link / unlink. - Spec deltas applied at archive time.
Rollback: revert the PR. The inverse migration restores access_token / refresh_token / expires_at from the JSONB blob and renames the table back. Production has small row count, so the inverse runs in seconds.
Open Questions
- Q1: Should
connected_services.statusbe an enum (active | needs_relink | revoked) at the DB level, or a free-form text column with constants in code? → Lean toward enum (Postgrestextwith aCHECKconstraint) so a stray status value can't slip in. Decide during implementation. - Q2: Where does the
oauthCredentialAdapterlive —lib/connected-services/credential-adapters/oauth.tsor co-located insideproviders/wahoo/? Wahoo's OAuth-specific config (token endpoint URL, client id) needs to be accessible to the adapter. → Lean toward shared adapter atcredential-adapters/oauth.tstaking the provider-specific config from the manifest. Decide during implementation. - Q3: Does the
oauthadapter handle revoke universally (callprovider.revokeUrlif defined), or is revoke a Wahoo-internal concern? → Lean toward shared with optional config, since Strava and Garmin also have revoke endpoints.