mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
274923a060
commit
0bdbf009f6
8 files changed
+126
-23
No files matched your search
+18
-9
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user