Add deepen-connected-services architecture artifacts
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>
This commit is contained in:
parent
ede480e652
commit
cfba3146e2
11 changed files with 595 additions and 0 deletions
19
docs/adr/0001-connected-services-credential-discriminator.md
Normal file
19
docs/adr/0001-connected-services-credential-discriminator.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Connected Services use a credential-kind discriminator + JSONB blob
|
||||
|
||||
trails.cool integrates with external services whose credentials have
|
||||
fundamentally different shapes: OAuth2 (Wahoo, future Coros/Garmin/Strava),
|
||||
web-login session (Komoot — no official API, we authenticate against the
|
||||
provider's web login with email + password and reuse the cookie jar),
|
||||
and device pairing (Apple Health, future Health Connect — no remote
|
||||
credential at all). We considered an OAuth-only schema with nullable extras
|
||||
and per-kind tables, and rejected both: nullable-OAuth would force Komoot
|
||||
and Apple Health to leave most columns NULL and bake OAuth assumptions into
|
||||
queries; per-kind tables would fragment `connected_services` and complicate
|
||||
the user-facing "Connected Services" list.
|
||||
|
||||
We store all linked external accounts in one `connected_services` table
|
||||
with a `credential_kind ∈ {oauth, web-login, device}` discriminator and a
|
||||
`credentials` JSONB blob whose schema is determined by kind. OAuth-specific
|
||||
`granted_scopes` stays a top-level column because feature gates query it
|
||||
directly. Per-kind `CredentialAdapter` modules own the credential lifecycle
|
||||
(refresh / relogin / noop); callers never read the JSONB directly.
|
||||
19
docs/adr/0002-no-unified-syncprovider-interface.md
Normal file
19
docs/adr/0002-no-unified-syncprovider-interface.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# No unified SyncProvider interface; capabilities are separate seams
|
||||
|
||||
The `connected-services` spec calls for a "provider-agnostic framework," and
|
||||
the obvious shape is a single `SyncProvider` interface with `connect /
|
||||
refresh / importOne / pushRoute / handleWebhook`. We rejected this because
|
||||
the providers we actually need to support are heterogeneous: Komoot is
|
||||
import-only via web-login (no refresh, no webhooks, no push), Apple Health
|
||||
is mobile-pushed (no server-initiated anything, no remote credential),
|
||||
Wahoo has all four. A unified interface would be OAuth-shaped by default
|
||||
and adapters would fill it with NOPs — shallow at the seam, leaky at the
|
||||
adapters.
|
||||
|
||||
Instead, capabilities are separate seams: `Importer`, `RoutePusher`,
|
||||
`WebhookReceiver`. Each provider declares which capabilities it implements
|
||||
in a co-located manifest (`providers/<name>/manifest.ts`); a small
|
||||
`registry.ts` imports each manifest. Credential lifecycle is the one thing
|
||||
shared across providers, and lives in `ConnectedServiceManager` +
|
||||
`CredentialAdapter` (per kind). If we ever genuinely need pan-provider
|
||||
behaviour, we add it to the manager — not to a provider interface.
|
||||
18
docs/adr/0003-routepusher-seam-shape.md
Normal file
18
docs/adr/0003-routepusher-seam-shape.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# RoutePusher seam takes (service, route); workarounds stay inside adapters
|
||||
|
||||
`RoutePusher` has one adapter today (Wahoo), but Coros, Garmin, and Strava
|
||||
push are all foreseeable. The current Wahoo push code exposes its
|
||||
workarounds — FIT Course conversion, the deterministic `external_id =
|
||||
route:<id>` convention, the PUT→POST-on-404 fallback when a user has
|
||||
deleted the route on the remote side — at the call site, which would force
|
||||
every future pusher to either inherit those Wahoo-isms or reshape the
|
||||
seam.
|
||||
|
||||
We commit to the seam shape now, with one adapter: `pushRoute(service,
|
||||
route) → {remoteId, version}`. Format conversion, idempotency tricks, and
|
||||
provider-specific HTTP recovery live entirely inside the adapter. The
|
||||
`sync_pushes` table (`(user_id, route_id, provider) → remote_id,
|
||||
last_pushed_version`) is the cross-provider contract for idempotency; the
|
||||
adapter is responsible for honouring it but not for exposing how. When the
|
||||
second pusher lands, it implements the same shape without changing
|
||||
callers.
|
||||
Loading…
Add table
Add a link
Reference in a new issue