Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-dump

This commit is contained in:
Marc-André Bombeck committed 2026-10-25 13:49:51 +01:00
commit adc96fde3e
56 files changed
+719 -390

No files matched your search

@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2897,
"total": 9249,
"total": 9243,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -360,7 +360,7 @@
"app/api/integrations/healthkit/__tests__/route.test.ts": 4,
"app/api/integrations/healthkit/route.ts": 3,
"app/api/integrations/status/__tests__/route.test.ts": 2,
"app/api/integrations/status/route.ts": 7,
"app/api/integrations/status/route.ts": 6,
"app/api/integrations/withings/resume/__tests__/route.test.ts": 1,
"app/api/integrations/withings/resume/route.ts": 1,
"app/api/internal/deploy-webhook/__tests__/route.test.ts": 2,
@@ -1381,7 +1381,7 @@
"components/settings/integrations/connections-panel.tsx": 5,
"components/settings/integrations/garmin-info-note.tsx": 1,
"components/settings/integrations/notification-channels-panel.tsx": 1,
"components/settings/integrations/setup-guide-link.tsx": 5,
"components/settings/integrations/setup-guide-link.tsx": 2,
"components/settings/integrations/shared.tsx": 3,
"components/settings/labs-section.tsx": 1,
"components/settings/layout-groups.ts": 2,
@@ -1737,7 +1737,7 @@
"lib/ai/coach/turn/reply-guards.ts": 18,
"lib/ai/coach/turn/reuse-turn.ts": 1,
"lib/ai/coach/turn/sse.ts": 10,
"lib/ai/coach/turn/steps.ts": 3,
"lib/ai/coach/turn/steps.ts": 1,
"lib/ai/coach/turn/types.ts": 17,
"lib/ai/coach/types.ts": 58,
"lib/ai/coach/visits-snapshot.ts": 1,
+5 -4
View File
@@ -502,8 +502,8 @@ export const GET = apiHandler(async () => {
} satisfies IntegrationViewModel & NightscoutExtras,
{
...publish(dawarichStatus),
// v1.43 — Dawarich, read by the card through `health`. The ledger core
// rides along like on every entry, and goes with the rest in v1.44.
// Dawarich, read by the card through `health`. The ledger core rides
// along like on every entry.
connected: dawarichConfigured,
configured: dawarichConfigured,
health: health["dawarich"],
@@ -524,8 +524,9 @@ interface IntegrationViewModel {
* health-shaped field on this entry (`state`, `lastError`,
* `consecutiveFailuresByKind`, `syncHealth`, `tokenExpired`, `needsReauth`,
* `hasActivityScope`, `planUnavailable`, `scopeReconnect`) is folded into
* it and goes once the settings cards render `health`; no other client
* reads this envelope.
* it. Those stay on the wire, deprecated in the published document; the
* cards render `health`, and only their copied diagnostics still read
* `consecutiveFailuresByKind`. No native client reads this envelope.
*/
health: ConnectionHealth | null;
state: string;
+1 -1
View File
@@ -99,7 +99,7 @@ export function BackupsSection() {
{/* External docs link — `noopener noreferrer` because this
leaves the admin shell. */}
<a
href="https://docs.healthlog.dev/admin/backups"
href="https://docs.healthlog.dev/guides/backups/"
target="_blank"
rel="noopener noreferrer"
className="text-primary inline-flex items-center gap-1 underline-offset-2 hover:underline"
@@ -1,7 +1,12 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
const record = vi.hoisted(() => ({ inSharedRecord: false }));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({ inSharedRecord: record.inSharedRecord }),
}));
import type { DocumentSuggestionDto } from "@/lib/validations/inbound-documents";
import {
AiUnavailableHint,
@@ -57,10 +62,26 @@ describe("<AiUnavailableHint>", () => {
expect(html).not.toContain('role="alert"');
});
it("nudges toward local OCR when a text-only provider needs it", () => {
it("points at the on-device scan switch when a text-only provider needs it", () => {
const html = render(<AiUnavailableHint reason="enable-local-ocr" />);
expect(html).toContain("local OCR");
expect(html).toContain('href="/settings/ai"');
// The switch lives on the Labs module page, under its own label.
expect(html).toContain("Read scans on this device");
expect(html).toContain("Settings → Modules → Labs");
expect(html).toContain('href="/settings/modules/labs#labs-local-ocr"');
expect(html).toContain("Open lab settings");
expect(html).not.toContain('href="/settings/ai"');
});
it("keeps the sentence but withholds the Settings link inside somebody else's record", () => {
record.inSharedRecord = true;
try {
const html = render(<AiUnavailableHint reason="enable-local-ocr" />);
expect(html).toContain("Read scans on this device");
expect(html).not.toContain('data-slot="assist-settings-link"');
expect(html).not.toContain('href="/settings');
} finally {
record.inSharedRecord = false;
}
});
});
@@ -1,7 +1,12 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
const record = vi.hoisted(() => ({ inSharedRecord: false }));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({ inSharedRecord: record.inSharedRecord }),
}));
import type { DocumentSuggestionDto } from "@/lib/validations/inbound-documents";
import { DocumentAiSection } from "../document-ai-section";
+32 -15
View File
@@ -22,6 +22,7 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import type { AiRunPhase } from "@/hooks/use-ai-run";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import {
@@ -64,9 +65,13 @@ export function AiRunPhaseNote({
}
/**
* Calm pointer to the AI settings, shown when assist is unavailable. When the
* document is already searchable (auto-indexed locally) the copy stays honest —
* it is searchable, an AI provider only adds a richer read.
* Calm pointer to the setting that would make assist available: the AI
* settings when no provider is configured, the on-device scan switch on the
* Labs module page when the provider cannot read images. Both live under
* `/settings`, which is the caller's own and closed inside somebody else's
* record, so the link is withheld there and the sentence stands alone. When
* the document is already searchable (auto-indexed locally) the copy stays
* honest — it is searchable, an AI provider only adds a richer read.
*/
export function AiUnavailableHint({
reason,
@@ -74,10 +79,11 @@ export function AiUnavailableHint({
reason: "no-provider" | "enable-local-ocr" | null;
}) {
const { t } = useTranslations();
const body =
reason === "enable-local-ocr"
? t("documents.assist.unavailableLocalOcr")
: t("documents.assist.unavailableBody");
const { inSharedRecord } = useRecordCapabilities();
const localOcr = reason === "enable-local-ocr";
const body = localOcr
? t("documents.assist.unavailableLocalOcr")
: t("documents.assist.unavailableBody");
return (
<div
data-slot="assist-unavailable"
@@ -85,14 +91,25 @@ export function AiUnavailableHint({
>
<Sparkles className="mt-0.5 size-3.5 shrink-0" aria-hidden />
<p className="min-w-0">
{body}{" "}
<Link
href="/settings/ai"
data-slot="assist-settings-link"
className="text-primary font-medium underline-offset-4 hover:underline"
>
{t("documents.assist.unavailableAction")}
</Link>
{body}
{!inSharedRecord && (
<>
{" "}
<Link
href={
localOcr
? "/settings/modules/labs#labs-local-ocr"
: "/settings/ai"
}
data-slot="assist-settings-link"
className="text-primary font-medium underline-offset-4 hover:underline"
>
{localOcr
? t("documents.assist.unavailableLocalOcrAction")
: t("documents.assist.unavailableAction")}
</Link>
</>
)}
</p>
</div>
);
@@ -251,8 +251,13 @@ describe("IntegrationsSection — one status display per card", () => {
for (const provider of PROVIDERS) {
expect(html).toContain(`data-testid="${provider}-setup-guide"`);
expect(html).toContain(
`href="https://docs.healthlog.dev/integrations/${provider}"`,
`href="https://docs.healthlog.dev/guides/connect-${provider}/"`,
);
}
// Garmin has no connector; its note goes straight to the section of the
// Google Health guide that explains the route, not through a redirect.
expect(html).toContain(
'href="https://docs.healthlog.dev/guides/connect-google-health/#garmin-owners"',
);
});
});
@@ -139,7 +139,7 @@ describe("<ManagedIntegrationStatus>", () => {
"Services you connect here send their data to Managed record&#x27;s record, not to yours.",
);
expect(html).toContain(
'href="https://docs.healthlog.dev/self-hosting/account-sharing#records-with-no-login"',
'href="https://docs.healthlog.dev/guides/managed-profiles/#connected-services"',
);
});
@@ -31,7 +31,7 @@ describe("<FitbitRetirementNotice>", () => {
expect(html).toContain("Google Health reads the same Fitbit and Pixel");
expect(html).toContain("Everything already imported stays.");
expect(html).toContain('href="#google-health"');
expect(html).toContain("/google-health");
expect(html).toContain("/guides/connect-google-health/");
});
it("says the connection has stopped once the day has come", () => {
@@ -11,7 +11,7 @@ import { ExternalLink, Watch } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { INTEGRATION_DOCS_BASE } from "@/components/settings/integrations/setup-guide-link";
import { GARMIN_DOCS_HREF } from "@/components/settings/integrations/setup-guide-link";
import { useTranslations } from "@/lib/i18n/context";
export function GarminInfoNote() {
@@ -25,7 +25,7 @@ export function GarminInfoNote() {
<p className="text-muted-foreground">
{t("settings.garminInfo.body")}{" "}
<a
href={`${INTEGRATION_DOCS_BASE}/garmin`}
href={GARMIN_DOCS_HREF}
target="_blank"
rel="noopener noreferrer"
data-testid="garmin-docs-link"
@@ -17,11 +17,16 @@ import { ExternalLink } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
/**
* Base for every provider setup runbook. The provider key is appended as a
* path segment, e.g. `https://docs.healthlog.dev/integrations/whoop`. Kept as
* a single constant so the host never drifts across cards.
* The docs site's guides, where every provider runbook lives at
* `connect-<provider>/`, e.g. `https://docs.healthlog.dev/guides/connect-whoop/`.
* These are the pages themselves: the older `/integrations/<provider>` paths
* only resolve through redirects. Kept as a single constant so the host never
* drifts across cards.
*/
export const INTEGRATION_DOCS_BASE = "https://docs.healthlog.dev/integrations";
export const DOCS_GUIDES_BASE = "https://docs.healthlog.dev/guides";
/** Garmin has no connector; the Google Health guide explains the route. */
export const GARMIN_DOCS_HREF = `${DOCS_GUIDES_BASE}/connect-google-health/#garmin-owners`;
export type IntegrationDocsProvider =
| "whoop"
@@ -30,15 +35,12 @@ export type IntegrationDocsProvider =
| "polar"
| "oura"
| "nightscout"
// v1.27.0 — Google Health runbook (docs.healthlog.dev/integrations/google-health).
| "google-health"
// v1.28.x — Strava runbook (docs.healthlog.dev/integrations/strava).
| "strava"
// v1.43 — Dawarich runbook (docs.healthlog.dev/integrations/dawarich).
| "dawarich";
export function integrationDocsHref(provider: IntegrationDocsProvider): string {
return `${INTEGRATION_DOCS_BASE}/${provider}`;
return `${DOCS_GUIDES_BASE}/connect-${provider}/`;
}
/**
@@ -54,7 +54,7 @@ const INTEGRATION_DISPLAY_NAMES = {
/** Where the guardian boundary is explained, connected services included. */
const SHARING_DOCS_HREF =
"https://docs.healthlog.dev/self-hosting/account-sharing#records-with-no-login";
"https://docs.healthlog.dev/guides/managed-profiles/#connected-services";
function iconFor(key: ManagedIntegrationKey) {
if (key === "nightscout") return Droplet;
+42 -1
View File
@@ -27,7 +27,8 @@ import {
summariseTitle,
} from "../persistence";
import { encryptToBytes } from "../bytes-codec";
import type { CoachProvenance } from "../types";
import { MAX_TURN_STEPS } from "../activity/contract";
import type { CoachProvenance, CoachStep } from "../types";
describe("summariseTitle", () => {
it("returns the input unchanged when below 80 chars", () => {
@@ -212,6 +213,46 @@ describe("appendMessage — provenance round-trip", () => {
expect(out.metricSource).toEqual(envelope);
});
it("restores every step the turn may record, and none past the cap", async () => {
const step = (n: number): CoachStep => ({
id: `s${n}`,
tool: "get_metric_series",
labelKey: "coach.step.readWindow",
label: "Read the series",
domain: "bp",
window: "last30days",
status: "done",
count: n,
});
const recorded = Array.from({ length: MAX_TURN_STEPS }, (_, i) =>
step(i + 1),
);
stubEchoCreate();
const full = await appendMessage({
conversationId: "conv_1",
role: "assistant",
content: "A long answer.",
metricSource: { windows: [], metrics: [], steps: recorded },
});
expect(full.metricSource?.steps).toEqual(recorded);
// A stored blob longer than the cap is cut at the cap, not before it.
stubEchoCreate(
JSON.stringify({
windows: [],
metrics: [],
steps: [...recorded, step(MAX_TURN_STEPS + 1)],
}),
);
const over = await appendMessage({
conversationId: "conv_1",
role: "assistant",
content: "An over-long blob.",
metricSource: { windows: [], metrics: [] },
});
expect(over.metricSource?.steps).toEqual(recorded);
});
it("tolerates a legacy blob without the new fields (no throw, fields absent)", async () => {
// A pre-feature row: only the original windows/metrics envelope on disk.
stubEchoCreate(
+7
View File
@@ -41,6 +41,13 @@ export const ACTIVITY_TEXT_MAX_CHARS = 400;
/** `a1`..`a99`: the 100th entry of a turn is not recorded. */
export const ACTIVITY_MAX_ENTRIES = 99;
/**
* `s1`..`s64`: the live steps of one turn (four per round of the longest
* turn). Later tool calls still run but show no step. The turn records up to
* this many and a reload restores up to this many, from this one constant.
*/
export const MAX_TURN_STEPS = 64;
export interface ActivityStart {
phase: CoachActivityPhase;
round: number;
+3 -2
View File
@@ -48,8 +48,9 @@
* `userId` is narrowed from the authenticated session by the caller and feeds
* the Prisma `where` field-by-field; it is never an input. Unlike the illness
* block, Labs is intentionally NOT module-gated (mirrors `/api/labs` — the data
* is always owner-scoped and safe to read), so the block is `null` only when the
* account has no recent readings.
* is always owner-scoped and safe to read). The block is `null` only when no
* reading falls inside the lookback limit (the whole record when there is
* none), or, for a named read, when no marker matches the analyte.
*/
import { sameLabUnit } from "@/lib/labs/unit-normalise";
import {
+7 -8
View File
@@ -28,7 +28,11 @@ import {
coachStopSchema,
coachTrailSchema,
} from "./stream-events";
import { ACTIVITY_MAX_ENTRIES, TRAIL_MAX_BYTES } from "./activity/contract";
import {
ACTIVITY_MAX_ENTRIES,
MAX_TURN_STEPS,
TRAIL_MAX_BYTES,
} from "./activity/contract";
import { commitTurnWrites } from "./memory/turn-writes";
import { COACH_CONVERSATION_TITLE_MAX } from "./types";
import { isRedundantViewChip } from "./follow-ups/view-chip";
@@ -56,7 +60,6 @@ import type {
CoachResultEntry,
CoachResultMeta,
CoachResultTable,
CoachStep,
CoachStepDomain,
CoachSuggestion,
CoachTrail,
@@ -225,11 +228,6 @@ function restoreOne<T>(raw: unknown, schema: z.ZodType<T>): T | undefined {
return parsed.success ? parsed.data : undefined;
}
/** The live steps a turn persisted (at most 48 per turn since v1.41). */
function restoreSteps(raw: unknown): CoachStep[] | undefined {
return restoreEach(raw, coachStepSchema, 48);
}
/** The method line; dropped whole when it does not parse. */
function restoreMethod(raw: unknown): CoachMethod | undefined {
const parsed = coachMethodSchema.safeParse(raw);
@@ -341,7 +339,8 @@ function provenanceFromJson(raw: string | null): CoachProvenance | null {
const toolCalls = restoreToolCalls(parsed.toolCalls);
// v1.39.4 — the dialog fields: steps, method, table metadata, chips, the
// clarification choices, and the forced-answer marker.
const steps = restoreSteps(parsed.steps);
// Up to the same cap the turn records steps under.
const steps = restoreEach(parsed.steps, coachStepSchema, MAX_TURN_STEPS);
const method = restoreMethod(parsed.method);
const results = restoreResultMetas(parsed.results);
// A reply stored before view chips were withheld for a table the answer
+2 -2
View File
@@ -668,8 +668,8 @@ async function buildCoachSnapshotImpl(
// scope-gated metric: attached without a `registerBlock` so the budget
// degrader never sheds it, and Labs is intentionally not module-gated (the
// helper reads owner-scoped rows directly, mirroring `/api/labs`). The block
// carries the most-recent resolved reading per biomarker (last 12 months,
// capped) so the Coach can answer "what was my LDL" without re-deriving.
// carries the latest resolved reading per biomarker (whole record or the
// lookback limit, capped) so the Coach can answer "what was my LDL".
const labsBlockPromise = buildLabsSnapshotBlock(userId, now, reach);
// v1.38 — doctor-visit context. Like illness/labs it is attempted always (a
@@ -34,7 +34,8 @@ import {
} from "@/lib/ai/coach/tools/definitions";
import type { CoachToolResult } from "@/lib/ai/coach/tools/run/types";
import { MAX_TURN_STEPS, snapshotStep, toStep } from "../steps";
import { MAX_TURN_STEPS } from "@/lib/ai/coach/activity/contract";
import { snapshotStep, toStep } from "../steps";
const SOURCES = coachScopeSourceSchema.options;
const WINDOWS = coachScopeWindowSchema.options;
+1 -7
View File
@@ -38,13 +38,7 @@ import {
} from "@/lib/ai/coach/tools/definitions";
import { isTurnResultRef } from "@/lib/ai/coach/results/refs";
import { COMPARE_SERIES_TOOL_NAME } from "@/lib/ai/coach/tools/compare-series";
/**
* At most this many steps per turn; later calls run but show no step.
* v1.41 — four per round of the longest turn: 48 for twelve rounds, 64 since
* v1.41.2 raised the cap to sixteen.
*/
export const MAX_TURN_STEPS = 64;
import { MAX_TURN_STEPS } from "@/lib/ai/coach/activity/contract";
const SCOPE_SOURCES: ReadonlySet<string> = new Set(
coachScopeSourceSchema.options,
@@ -2,8 +2,8 @@
* Doc ↔ code parity guard for the Google Health operator runbook.
*
* The setup-guide link on the Settings card sends a mid-setup user to
* `docs.healthlog.dev/integrations/google-health`, rendered from
* `docs/integrations/google-health.md`. If the client ever requests a scope the
* `docs.healthlog.dev/guides/connect-google-health/`; the operator runbook in
* this repository is `docs/integrations/google-health.md`. If the client ever requests a scope the
* runbook doesn't list, or the walkthrough regresses back to a wrong provider
* (the file was once a Fitbit-Web-API walkthrough), a self-hoster follows a
* broken recipe and the OAuth consent screen rejects the connection.
@@ -20,7 +20,7 @@ import { describe, expect, it } from "vitest";
import { GOOGLE_HEALTH_CORE_SCOPES } from "@/lib/google-health/oauth";
import {
INTEGRATION_DOCS_BASE,
DOCS_GUIDES_BASE,
integrationDocsHref,
} from "@/components/settings/integrations/setup-guide-link";
@@ -70,7 +70,7 @@ describe("google-health setup runbook parity", () => {
// The literal argument also compile-checks that "google-health" is a member
// of the IntegrationDocsProvider union.
expect(integrationDocsHref("google-health")).toBe(
`${INTEGRATION_DOCS_BASE}/google-health`,
`${DOCS_GUIDES_BASE}/connect-google-health/`,
);
});
});
@@ -411,6 +411,74 @@
"grounding": {
"type": "string"
},
"searchedWindow": {
"type": "string"
},
"available": {
"type": "object",
"properties": {
"count": {
"type": "number"
},
"firstDate": {
"type": "string"
},
"lastDate": {
"type": "string"
},
"series": {
"type": "array",
"items": {
"type": "object",
"properties": {
"series": {
"type": "string"
},
"unit": {
"type": [
"string",
"null"
]
},
"count": {
"type": "number"
},
"mean": {
"type": "number"
},
"min": {
"type": "number"
},
"max": {
"type": "number"
}
},
"required": [
"series",
"unit",
"count",
"mean",
"min",
"max"
],
"additionalProperties": false
}
},
"reachableWithWindow": {
"type": [
"string",
"null"
]
}
},
"required": [
"count",
"firstDate",
"lastDate",
"reachableWithWindow"
],
"additionalProperties": false
},
"analyte": {
"type": "string"
},
+46
View File
@@ -510,6 +510,52 @@ describe("get_illness_recovery — condition body site", () => {
});
describe("get_labs", () => {
it.each([{}, { analyte: "LDL" }])(
"declares empty-read availability for ordinary and filtered reads: %j",
async (args) => {
const { z } = await import("zod/v4");
const empty = {
present: false,
reason: "out_of_window",
searchedWindow: "lastYear",
available: {
count: 2,
firstDate: "2020-01-01",
lastDate: "2020-01-02",
reachableWithWindow: null,
},
};
vi.mocked(executeCoachTool).mockResolvedValue(empty);
const def = tool("get_labs");
const result = await def.run(CTX, args);
expect(executeCoachTool).toHaveBeenCalledWith({
userId: CTX.userId,
name: "get_labs",
rawArguments: JSON.stringify(args),
});
expect(z.object(def.outputShape!).strict().parse(result)).toEqual(empty);
},
);
it("keeps output validation closed and availability typed", async () => {
const { z } = await import("zod/v4");
const schema = z.object(tool("get_labs").outputShape!).strict();
expect(z.toJSONSchema(schema).additionalProperties).toBe(false);
expect(schema.safeParse({ present: false, unexpected: true }).success).toBe(
false,
);
expect(
schema.safeParse({ present: false, searchedWindow: 7 }).success,
).toBe(false);
expect(
schema.safeParse({ present: false, available: { count: "2" } }).success,
).toBe(false);
expect(schema.parse({ present: false, reason: "no_data" })).toEqual({
present: false,
reason: "no_data",
});
});
it("forwards the optional analyte filter and returns readings with units + bands", async () => {
vi.mocked(executeCoachTool).mockResolvedValue({
present: true,
+3 -5
View File
@@ -4,16 +4,14 @@ import { resolveMarkerForRead } from "@/lib/labs/biomarker-resolve";
import { getLabHistory, LAB_HISTORY_MAX_LIMIT } from "@/lib/mcp/rich-reads";
import { decodeOffsetCursor } from "@/lib/mcp/pagination";
import type { McpAuthContext } from "../auth";
import { coachReadOutput } from "./output-shapes";
import { runCoachTool, whenModuleOn } from "./shared";
import { READ_ONLY_ANNOTATIONS, type McpToolDefinition } from "./types";
/** Output schema for `get_labs` — latest-per-biomarker OR a paginated history. */
const getLabsOutput: z.ZodRawShape = {
present: z.boolean(),
reason: z.string().optional(),
// Latest-mode payload (the Coach labs section, carried opaque).
data: z.unknown().optional(),
grounding: z.string().optional(),
// Latest-mode payload, including typed empty-read availability metadata.
...coachReadOutput,
// History-mode payload (per-analyte trajectory + pagination).
analyte: z.string().optional(),
readings: z
+7 -5
View File
@@ -75,10 +75,12 @@ const healthKitEntry = z
"One resolved HealthKit metric mapping (defaults merged with the user's stored overrides).",
});
// The health-shaped fields that predate `health` (v1.42.4). They stay on the
// wire through v1.43 so App 1.2.0 keeps decoding, are marked deprecated in the
// document, and go in v1.44.
const LEGACY_HEALTH_FIELD = "Deprecated: removed in v1.44, read `health`.";
// The health-shaped fields that predate `health`. They are marked deprecated
// in the document and stay on the wire: native builds from before `health`
// still decode them, the current one falls back to them only against a server
// that sends no `health`, and the web card's copied diagnostics read the
// failure streak. No removal release is set.
const LEGACY_HEALTH_FIELD = "Deprecated, still served: read `health`.";
function legacy(text?: string): { deprecated: true; description: string } {
return {
@@ -1184,7 +1186,7 @@ const integrationEnvelopeEntry = z
health: connectionHealth
.nullable()
.describe(
"The unified connection state, null when the provider is not connected (and for Fitbit once `retirement.ended`). It supersedes `state`, `lastError`, `consecutiveFailuresByKind`, `syncHealth`, `tokenExpired`, `needsReauth`, `hasActivityScope`, `planUnavailable` and `scopeReconnect` on this entry; those nine are deprecated and removed in v1.44.",
"The unified connection state, null when the provider is not connected (and for Fitbit once `retirement.ended`). It supersedes `state`, `lastError`, `consecutiveFailuresByKind`, `syncHealth`, `tokenExpired`, `needsReauth`, `hasActivityScope`, `planUnavailable` and `scopeReconnect` on this entry; those nine are deprecated and still served for clients that predate `health`.",
),
})
.meta({