diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index 4cfe63778..94f0c047d 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -78,6 +78,12 @@ roll back: dev --create-only --name vX_Y_Z_rollback_`), edit the SQL, commit, and re-deploy. Never `migrate resolve --rolled-back` against production unless you've taken a fresh `pg_dump` first. +4. Rolling back from v1.40.2 or later to v1.40.1 or earlier: an account + that saved the Coach lookback "Last year" reads its whole Coach + preference blob as defaults on the older version, metric exclusions + included, until the setting is saved again. Ask affected accounts to + pick another lookback before the rollback, or re-save their exclusions + after it. ## Catalog templates diff --git a/src/lib/validations/__tests__/coach-prefs.test.ts b/src/lib/validations/__tests__/coach-prefs.test.ts index cb3f8c6bc..801a347f4 100644 --- a/src/lib/validations/__tests__/coach-prefs.test.ts +++ b/src/lib/validations/__tests__/coach-prefs.test.ts @@ -154,13 +154,30 @@ describe("parseCoachPrefs", () => { expect(out.dataClusters).toEqual([]); }); - it("falls back to defaults when dataClusters carries an unknown cluster", () => { - // Shape drift — an unknown cluster string fails the enum, the whole - // parse fails, and parseCoachPrefs returns the legacy defaults - // (dataClusters undefined → legacy cluster expansion). + it("keeps the known clusters when dataClusters carries an unknown one", () => { + // Shape drift: a cluster a newer version added. Falling back to the + // legacy default set would widen what the Coach reads past what the + // person chose; dropping only the unknown entry narrows it. const out = parseCoachPrefs({ dataClusters: ["cardio", "astrology"] }); - expect(out).toEqual(DEFAULT_COACH_PREFS); - expect(out.dataClusters).toBeUndefined(); + expect(out.dataClusters).toEqual(["cardio"]); + }); + + it("lets an invalid field fall back alone and keeps the exclusions", () => { + // A window value this version does not know (written by a newer one, + // read after a rollback) must not take the metric exclusions with it. + const out = parseCoachPrefs({ + tone: "neutral", + defaultWindow: "lastDecade", + excludeMetrics: ["mood", "weight"], + }); + expect(out.defaultWindow).toBe("allTime"); + expect(out.excludeMetrics).toEqual(["mood", "weight"]); + expect(out.tone).toBe("neutral"); + }); + + it("keeps the known exclusions when the list carries an unknown metric", () => { + const out = parseCoachPrefs({ excludeMetrics: ["mood", "aura"] }); + expect(out.excludeMetrics).toEqual(["mood"]); }); it("DEFAULT_COACH_CLUSTERS preserves the legacy five domains' clusters", () => { diff --git a/src/lib/validations/coach-prefs.ts b/src/lib/validations/coach-prefs.ts index c74132f41..b1ea83a90 100644 --- a/src/lib/validations/coach-prefs.ts +++ b/src/lib/validations/coach-prefs.ts @@ -216,13 +216,44 @@ export const DEFAULT_COACH_PREFS: CoachPrefs = { /** * Parse a row's `coachPrefsJson` Json blob into a typed `CoachPrefs`, - * falling back to defaults when the row is null OR the persisted shape - * has drifted (a forward-compat field rename, an admin-side hand-edit, - * etc.). Keeps the call sites at `src/lib/ai/coach/snapshot.ts` and - * `src/lib/ai/coach/system-prompt.ts` free of the null/parse plumbing. + * falling back to defaults when the row is null. Keeps the call sites at + * `src/lib/ai/coach/snapshot.ts` and `src/lib/ai/coach/system-prompt.ts` + * free of the null/parse plumbing. + * + * A drifted shape (a value a newer version wrote and this one reads after a + * rollback, a hand-edit) is salvaged field by field: an invalid field falls + * back to its own default and every valid one stays. Parsing the blob as a + * whole used to drop everything on one bad field, metric exclusions + * included, which turned an unknown lookback value into a privacy loss. The + * two lists keep their known entries, so an unknown metric or cluster + * narrows what the Coach reads rather than widening it. */ export function parseCoachPrefs(raw: unknown): CoachPrefs { if (raw == null) return DEFAULT_COACH_PREFS; - const parsed = coachPrefsSchema.safeParse(raw); - return parsed.success ? parsed.data : DEFAULT_COACH_PREFS; + const whole = coachPrefsSchema.safeParse(raw); + if (whole.success) return whole.data; + if (typeof raw !== "object" || Array.isArray(raw)) { + return DEFAULT_COACH_PREFS; + } + const source = raw as Record; + const salvaged: Record = {}; + for (const [key, field] of Object.entries(coachPrefsSchema.shape)) { + if (!(key in source)) continue; + const value = knownItemsOnly(key, source[key]); + if (field.safeParse(value).success) salvaged[key] = value; + } + if (Object.keys(salvaged).length === 0) return DEFAULT_COACH_PREFS; + return coachPrefsSchema.parse(salvaged); +} + +/** The entries of a list preference this version knows; other values as is. */ +function knownItemsOnly(key: string, value: unknown): unknown { + if (!Array.isArray(value)) return value; + const item = + key === "excludeMetrics" + ? coachExcludeMetricEnum + : key === "dataClusters" + ? coachDataClusterEnum + : null; + return item ? value.filter((entry) => item.safeParse(entry).success) : value; }