mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(coach): salvage Coach preferences field by field
parseCoachPrefs parsed the stored blob as a whole and returned the defaults on any invalid field. A lookback value this version does not know therefore dropped the metric exclusions with it. Each field now falls back on its own, and the exclusion and cluster lists keep their known entries, so drift narrows what the Coach reads instead of widening it. The deploy runbook's rollback section notes the one case this cannot cover: v1.40.1 and earlier still parse the blob whole, so an account that saved the new "Last year" lookback loses its exclusions there.
This commit is contained in:
1 parent
1158ae9ffd
commit
169c7bdfe6
3 files changed
+66
-12
No files matched your search
@@ -78,6 +78,12 @@ roll back:
|
||||
dev --create-only --name vX_Y_Z_rollback_<col>`), 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
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
const salvaged: Record<string, unknown> = {};
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user