fix(integrations): a provider with no history is "unknown"

The ledger's synthetic "no row" snapshot returned `state: "connected"`, so
the field's default meant the opposite of its name: every account that had
never connected a provider published `connected` on the status envelope.
Settings survived it by never reading `state`; the setup flow's connect step
did read it, and told brand-new accounts their wearable was connected.

The snapshot now reads `unknown`. `syncHealth.verdict` is unchanged and stays
the only liveness truth; the managed-record projection maps `unknown` onto
`disconnected` the way it already maps a missing connection.
This commit is contained in:
Marc-André Bombeck committed 2026-09-11 09:08:38 +02:00
1 parent 274923a060
commit 0bdbf009f6
8 files changed
+126 -23

No files matched your search

+18 -9
View File
@@ -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
@@ -60,6 +60,7 @@ const whoopFind = prisma.whoopConnection.findUnique as ReturnType<typeof vi.fn>;
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");
});
});
@@ -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;
@@ -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;
@@ -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();
});
});
+22 -5
View File
@@ -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,
+2 -1
View File
@@ -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({
+6 -1
View File
@@ -14,5 +14,10 @@ export function resolveManagedIntegrationState(
connected: Record<IntegrationKey, boolean>,
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;
}