diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6ba15e063..b674ba177 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -64323,9 +64323,12 @@ components: - error_reauth - disconnected - parked - description: Last recorded ledger state for this provider. `connected` is also the value a user who has never synced - sees — the ledger row is created on the first attempt, and its absence reads as `connected`, not as an - error. + - unknown + description: "Last recorded ledger state for this provider. `unknown` (added v1.38.19) is what a user who has never + synced sees: the ledger row is created on the first attempt, and its absence is no claim about the + connection in either direction — it used to read as `connected`, which is why a client must never treat this + field as liveness. Read `syncHealth.verdict` for that, and treat `unknown` as `disconnected` anywhere a + ledger state is painted." lastSuccessAt: anyOf: - type: string @@ -65318,9 +65321,12 @@ components: - error_reauth - disconnected - parked - description: Last recorded ledger state for this provider. `connected` is also the value a user who has never synced - sees — the ledger row is created on the first attempt, and its absence reads as `connected`, not as an - error. + - unknown + description: "Last recorded ledger state for this provider. `unknown` (added v1.38.19) is what a user who has never + synced sees: the ledger row is created on the first attempt, and its absence is no claim about the + connection in either direction — it used to read as `connected`, which is why a client must never treat this + field as liveness. Read `syncHealth.verdict` for that, and treat `unknown` as `disconnected` anywhere a + ledger state is painted." lastSuccessAt: anyOf: - type: string @@ -65724,9 +65730,12 @@ components: - error_reauth - disconnected - parked - description: Last recorded ledger state for this provider. `connected` is also the value a user who has never synced - sees — the ledger row is created on the first attempt, and its absence reads as `connected`, not as an - error. + - unknown + description: "Last recorded ledger state for this provider. `unknown` (added v1.38.19) is what a user who has never + synced sees: the ledger row is created on the first attempt, and its absence is no claim about the + connection in either direction — it used to read as `connected`, which is why a client must never treat this + field as liveness. Read `syncHealth.verdict` for that, and treat `unknown` as `disconnected` anywhere a + ledger state is painted." lastSuccessAt: anyOf: - type: string diff --git a/src/app/api/integrations/status/__tests__/route.test.ts b/src/app/api/integrations/status/__tests__/route.test.ts index 31f546594..75c79e084 100644 --- a/src/app/api/integrations/status/__tests__/route.test.ts +++ b/src/app/api/integrations/status/__tests__/route.test.ts @@ -60,6 +60,7 @@ const whoopFind = prisma.whoopConnection.findUnique as ReturnType; type Entry = { integration: string; + state?: string; connected?: boolean; configured?: boolean; available?: boolean; @@ -344,3 +345,41 @@ describe("/api/integrations/status — the freshness read failed", () => { } }); }); + +/** + * v1.38.19 (wave B / I5) — the never-connected provider on the wire. + * + * The ledger's synthetic "no row" snapshot reads `unknown`, and the envelope + * publishes it verbatim. A client that reads `state` must see a value that + * cannot be mistaken for a live connection, while `syncHealth.verdict` keeps + * saying `disconnected` for the same entry. + */ +describe("/api/integrations/status — a provider with no ledger row", () => { + beforeEach(() => { + vi.clearAllMocks(); + for (const key of Object.keys(perIntegrationLedger)) { + delete perIntegrationLedger[key]; + } + polarAvailable.mockResolvedValue(true); + ouraAvailable.mockResolvedValue(false); + }); + + it("publishes state unknown beside connected false and a disconnected verdict", async () => { + perIntegrationLedger.whoop = { + state: "unknown", + lastSuccessAt: null, + lastAttemptAt: null, + lastError: null, + failingSince: null, + }; + userFind.mockResolvedValue({}); + whoopFind.mockResolvedValue(null); + + const whoop = (await fetchEntries()).find( + (e) => e.integration === "whoop", + )!; + expect(whoop.state).toBe("unknown"); + expect(whoop.connected).toBe(false); + expect(whoop.syncHealth?.verdict).toBe("disconnected"); + }); +}); diff --git a/src/components/settings/integrations/oauth-provider-card.tsx b/src/components/settings/integrations/oauth-provider-card.tsx index 4b8d2e5b7..7435f0330 100644 --- a/src/components/settings/integrations/oauth-provider-card.tsx +++ b/src/components/settings/integrations/oauth-provider-card.tsx @@ -63,6 +63,7 @@ import { IntegrationErrorMessage, pillFailurePropsFor, pillStateForVerdict, + type IntegrationState, } from "./shared"; import { CallbackMismatchNotice, @@ -78,12 +79,12 @@ export interface OAuthProviderStatus { available: boolean; /** Whether the user has stored their own BYO client id/secret pair. */ hasOwnCredentials?: boolean; - state?: - | "connected" - | "error_transient" - | "error_reauth" - | "disconnected" - | "parked"; + /** + * The raw ledger state, carried for shape parity with the envelope. The + * card paints from `syncHealth.verdict`, never from this — which is why + * v1.38.19's `unknown` member needs no arm here. + */ + state?: IntegrationState; lastSuccessAt?: string | null; lastAttemptAt?: string | null; lastError?: string | null; diff --git a/src/components/settings/integrations/shared.tsx b/src/components/settings/integrations/shared.tsx index ffcbc70d9..61f759367 100644 --- a/src/components/settings/integrations/shared.tsx +++ b/src/components/settings/integrations/shared.tsx @@ -36,8 +36,16 @@ export type IntegrationKey = // Nightscout folds onto the envelope so the card stops fetching its own // status and inherits the shared verdict. | "nightscout"; +/** Mirrors `IntegrationState` in `src/lib/integrations/status.ts`. */ export type IntegrationState = - "connected" | "error_transient" | "error_reauth" | "disconnected" | "parked"; + | "connected" + | "error_transient" + | "error_reauth" + | "disconnected" + | "parked" + // v1.38.19 — no ledger row: the ledger has no history for this provider. + // Read like `disconnected`; `syncHealth.verdict` owns liveness. + | "unknown"; export interface IntegrationStatusViewModel { integration: IntegrationKey; diff --git a/src/lib/integrations/__tests__/status.test.ts b/src/lib/integrations/__tests__/status.test.ts index 6f689fba9..99845eedd 100644 --- a/src/lib/integrations/__tests__/status.test.ts +++ b/src/lib/integrations/__tests__/status.test.ts @@ -48,6 +48,7 @@ vi.mock("@/lib/crypto", () => ({ })); import { + getIntegrationStatus, recordSyncFailure, recordSyncSuccess, markReauthRequired, @@ -1368,3 +1369,25 @@ describe("recordSyncFailure — leg set", () => { expect(writtenUpdate().failingLegs).toEqual(["sleep"]); }); }); + +/** + * v1.38.19 (wave B / I5) — the synthetic "no row" snapshot. + * + * `state: "connected"` used to be the value an account with no sync history + * read back, which made the ledger field's default mean the opposite of its + * name: a provider nobody had ever connected published "connected" on the + * envelope, and the setup flow's connect step believed it. The honest value + * for "there is no history here" is `unknown`; `syncHealth.verdict` stays the + * only liveness truth. + */ +describe("getIntegrationStatus with no ledger row", () => { + it("reads as unknown, not as connected", async () => { + vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce( + null as never, + ); + const snapshot = await getIntegrationStatus("u1", "whoop"); + expect(snapshot.state).toBe("unknown"); + expect(snapshot.lastSuccessAt).toBeNull(); + expect(snapshot.lastAttemptAt).toBeNull(); + }); +}); diff --git a/src/lib/integrations/status.ts b/src/lib/integrations/status.ts index 2fa914b4f..96e730cb5 100644 --- a/src/lib/integrations/status.ts +++ b/src/lib/integrations/status.ts @@ -159,7 +159,22 @@ export function isSyncFailureRecorded(err: unknown): boolean { * trail. */ export type IntegrationState = - "connected" | "error_transient" | "error_reauth" | "disconnected" | "parked"; + | "connected" + | "error_transient" + | "error_reauth" + | "disconnected" + | "parked" + /** + * v1.38.19 — no ledger row exists for this (user, provider) pair, so the + * ledger has nothing to say. It is NOT a claim about the connection either + * way: `connected` used to be the synthetic default here, which made the + * field's default read as the opposite of the truth for every account that + * had never connected anything — the setup flow's connect step believed it + * and told a brand-new account its wearable was connected. Consumers treat + * `unknown` the way they treat `disconnected`; `syncHealth.verdict` stays + * the only liveness truth. + */ + | "unknown"; /** * The ladder at which a streak of failures escalates from "user-visible @@ -323,9 +338,11 @@ function zeroBuckets(): ConsecutiveFailuresByKind { } /** - * Read the current snapshot. Returns a synthetic "connected, never - * attempted" record when no row exists yet — the UI treats this as - * "no sync history" without a special case. + * Read the current snapshot. Returns a synthetic "unknown, never attempted" + * record when no row exists yet — the ledger holds no history, and says so + * rather than guessing a state. `resolveSyncVerdict` reads the same absence + * off the null timestamps and answers `pending_first_sync` for a live + * connection, `disconnected` for one that is not there. */ export async function getIntegrationStatus( userId: string, @@ -337,7 +354,7 @@ export async function getIntegrationStatus( if (!row) { return { integration, - state: "connected", + state: "unknown", lastSuccessAt: null, lastAttemptAt: null, lastError: null, diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts index 13fcf3981..020549058 100644 --- a/src/lib/openapi/routes/integrations.ts +++ b/src/lib/openapi/routes/integrations.ts @@ -326,9 +326,10 @@ const integrationLedgerState = z "error_reauth", "disconnected", "parked", + "unknown", ]) .describe( - "Last recorded ledger state for this provider. `connected` is also the value a user who has never synced sees — the ledger row is created on the first attempt, and its absence reads as `connected`, not as an error.", + "Last recorded ledger state for this provider. `unknown` (added v1.38.19) is what a user who has never synced sees: the ledger row is created on the first attempt, and its absence is no claim about the connection in either direction — it used to read as `connected`, which is why a client must never treat this field as liveness. Read `syncHealth.verdict` for that, and treat `unknown` as `disconnected` anywhere a ledger state is painted.", ); const nightscoutConnectRequest = nightscoutConnectSchema.meta({ diff --git a/src/lib/record-settings/integrations.ts b/src/lib/record-settings/integrations.ts index 192dd7e7c..cadea4616 100644 --- a/src/lib/record-settings/integrations.ts +++ b/src/lib/record-settings/integrations.ts @@ -14,5 +14,10 @@ export function resolveManagedIntegrationState( connected: Record, integration: IntegrationKey, ): IntegrationState { - return connected[integration] ? state : "disconnected"; + if (!connected[integration]) return "disconnected"; + // v1.38.19 — `unknown` is the ledger saying it holds no row. The managed + // projection publishes a closed vocabulary its card paints from, and + // "no history" is not one of its states, so it reads as `disconnected` + // here exactly as it does everywhere else. + return state === "unknown" ? "disconnected" : state; }