mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Merge the persisted score record into the composition swap
This commit is contained in:
commit
0826bd80e6
18 files changed
+1614
-7
No files matched your search
@@ -257,6 +257,29 @@ function sourceClient() {
|
||||
},
|
||||
]),
|
||||
},
|
||||
// The score as it was SHOWN on a local day. Not recomputable: today's
|
||||
// number always comes from today's rows, so once the readings behind this
|
||||
// day moved, nothing can reproduce what it said.
|
||||
healthScoreRecord: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "score-day-1",
|
||||
userId: OWNER,
|
||||
dayKey: "2026-07-18",
|
||||
timezone: "Europe/Berlin",
|
||||
composite: 76,
|
||||
band: "yellow",
|
||||
scoreVersion: 2,
|
||||
composition: ["BLOOD_PRESSURE", "SLEEP", "ADIPOSITY"],
|
||||
pillarScores: { BLOOD_PRESSURE: 86, SLEEP: 100, ADIPOSITY: 42 },
|
||||
inputFingerprint: "a".repeat(64),
|
||||
configVersion: null,
|
||||
configChangedAt: null,
|
||||
computedAt: new Date("2026-07-18T20:00:00.000Z"),
|
||||
createdAt: new Date("2026-07-18T20:00:00.000Z"),
|
||||
},
|
||||
]),
|
||||
},
|
||||
customMetric: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
@@ -586,6 +609,44 @@ describe("backup round trip — export, wire schema, restore", () => {
|
||||
expect(rows[0].hourlyCumulative).toEqual(DAY_CURVE);
|
||||
});
|
||||
|
||||
it("writes the recorded score day back with the number it showed", async () => {
|
||||
const { written } = await roundTrip();
|
||||
|
||||
const write = written.find(
|
||||
(w) => w.model === "healthScoreRecord" && w.op === "createMany",
|
||||
);
|
||||
expect(
|
||||
write,
|
||||
"the recorded score day reached the file and nothing wrote it back",
|
||||
).toBeDefined();
|
||||
|
||||
const rows = write!.data as unknown as Array<Record<string, unknown>>;
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
id: "score-day-1",
|
||||
userId: OWNER,
|
||||
dayKey: "2026-07-18",
|
||||
timezone: "Europe/Berlin",
|
||||
composite: 76,
|
||||
band: "yellow",
|
||||
scoreVersion: 2,
|
||||
inputFingerprint: "a".repeat(64),
|
||||
});
|
||||
// The composition and the per-pillar scores are what keep the stored day
|
||||
// readable across a later change to what counts. A row that came back
|
||||
// with only its headline would look restored and answer nothing.
|
||||
expect(rows[0].composition).toEqual([
|
||||
"BLOOD_PRESSURE",
|
||||
"SLEEP",
|
||||
"ADIPOSITY",
|
||||
]);
|
||||
expect(rows[0].pillarScores).toEqual({
|
||||
BLOOD_PRESSURE: 86,
|
||||
SLEEP: 100,
|
||||
ADIPOSITY: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it("writes the account's own cycle symptom back before resolving its links", async () => {
|
||||
const { written, res } = await roundTrip();
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
|
||||
import { restoreCycleData } from "@/lib/cycle/backup";
|
||||
import { restoreProfileData } from "@/lib/export/profile-backup";
|
||||
import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup";
|
||||
import { restoreHealthScoreData } from "@/lib/export/health-score-backup";
|
||||
import { invalidateUserData } from "@/lib/cache/invalidate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -71,6 +72,7 @@ interface RestoreResponse {
|
||||
customMetrics: number;
|
||||
correlationPatterns: number;
|
||||
intradayProfiles: number;
|
||||
healthScoreRecords: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -764,6 +766,14 @@ const handler = apiHandler(
|
||||
payload,
|
||||
);
|
||||
|
||||
// The score as it was shown on each local day. Both ends live in
|
||||
// `src/lib/export/health-score-backup.ts`, same as the two above.
|
||||
const healthScoreCleared = await restoreHealthScoreData(
|
||||
tx,
|
||||
ownerId,
|
||||
payload,
|
||||
);
|
||||
|
||||
const biomarkerByName = new Map<string, string>();
|
||||
const restoredBiomarkerIds = new Set<string>();
|
||||
for (const biomarker of payload.biomarkers) {
|
||||
@@ -1128,6 +1138,7 @@ const handler = apiHandler(
|
||||
customMetrics: profileCleared.customMetrics,
|
||||
correlationPatterns: profileCleared.correlationPatterns,
|
||||
intradayProfiles: intradayCleared.intradayProfiles,
|
||||
healthScoreRecords: healthScoreCleared.healthScoreRecords,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
computeBpInTargetFastPath,
|
||||
type BpInTargetEnvelope,
|
||||
} from "@/lib/analytics/bp-in-target-fast-path";
|
||||
import { computeUserHealthScore } from "@/lib/analytics/score/reader";
|
||||
import { computeAndRecordUserHealthScore } from "@/lib/analytics/score/record";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { resolveRestMode } from "@/lib/illness/rest-mode";
|
||||
import { deriveBpWindow90 } from "@/lib/analytics/window-confidence";
|
||||
@@ -554,7 +554,7 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
|
||||
const glucoseByContextOut = glucoseEnabled ? glucoseByContext : null;
|
||||
const glucoseClinicalOut = glucoseEnabled ? glucoseClinical : null;
|
||||
const scoredAt = new Date();
|
||||
const healthScore = await computeUserHealthScore({
|
||||
const healthScore = await computeAndRecordUserHealthScore({
|
||||
userId: user.id,
|
||||
now: scoredAt,
|
||||
profile: {
|
||||
|
||||
@@ -27,6 +27,7 @@ vi.mock("@/lib/db", () => ({
|
||||
// The hourly shape of a cumulative day, read by
|
||||
// `buildIntradayProfileBackupSection`.
|
||||
intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// v1.15.0 — cycle tables read by the full-backup helper.
|
||||
cycleProfile: { findUnique: vi.fn() },
|
||||
menstrualCycle: { findMany: vi.fn() },
|
||||
@@ -96,6 +97,7 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.intradayCumulativeProfile.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.healthScoreRecord.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -25,6 +25,7 @@ vi.mock("@/lib/db", () => ({
|
||||
// The hourly shape of a cumulative day, read by
|
||||
// `buildIntradayProfileBackupSection`.
|
||||
intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* The shape of a stored score day, and the promise never to restate one.
|
||||
*
|
||||
* The database half of that promise is proved against real Postgres in
|
||||
* `tests/integration/health-score-record.test.ts`; this file covers the parts
|
||||
* a database cannot see: what the fingerprint covers, which pillars reach the
|
||||
* row, and that the write path reaches for an insert that yields rather than
|
||||
* an update that overwrites.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { HealthScoreBand } from "@/generated/prisma/client";
|
||||
import { buildOk, deriveCoverage } from "@/lib/insights/derived/coverage";
|
||||
import type { Derived } from "@/lib/insights/derived/types";
|
||||
|
||||
import { computeComposite } from "../composite";
|
||||
|
||||
import {
|
||||
buildHealthScoreRecord,
|
||||
healthScoreInputFingerprint,
|
||||
recordHealthScore,
|
||||
SCORE_BANDS,
|
||||
} from "../record";
|
||||
import type {
|
||||
HealthScoreReport,
|
||||
PillarValue,
|
||||
ScorePillarId,
|
||||
ScorePillarResult,
|
||||
} from "../types";
|
||||
|
||||
const NOW = new Date("2026-08-20T12:00:00.000Z");
|
||||
|
||||
const DOMAIN_BY_PILLAR: Record<ScorePillarId, ScorePillarResult["domain"]> = {
|
||||
BLOOD_PRESSURE: "cardiometabolic",
|
||||
GLYCAEMIA: "cardiometabolic",
|
||||
ACTIVITY: "activity",
|
||||
SLEEP: "sleep",
|
||||
ADIPOSITY: "adiposity",
|
||||
WELLBEING: "wellbeing",
|
||||
FITNESS: "fitness",
|
||||
LIPIDS: "cardiometabolic",
|
||||
};
|
||||
|
||||
function pillar(
|
||||
id: ScorePillarId,
|
||||
score: number,
|
||||
options: { deltaIdentity?: string } = {},
|
||||
): ScorePillarResult {
|
||||
const { coverage, confidence } = deriveCoverage({
|
||||
requiredInputs: 1,
|
||||
presentInputs: 1,
|
||||
historyDays: 28,
|
||||
missing: [],
|
||||
fullHistoryDays: 28,
|
||||
});
|
||||
const value: PillarValue = {
|
||||
score,
|
||||
observed: {
|
||||
label: `${score}`,
|
||||
value: score,
|
||||
unit: "score",
|
||||
asOf: NOW.toISOString(),
|
||||
sources: ["MANUAL"],
|
||||
},
|
||||
reference: {
|
||||
kind: "guideline-band",
|
||||
low: 0,
|
||||
high: 100,
|
||||
label: "test reference",
|
||||
source: "Test 2026",
|
||||
},
|
||||
noiseFloor: 1,
|
||||
deltaEligible: true,
|
||||
deltaIdentity: options.deltaIdentity ?? id,
|
||||
};
|
||||
const result: Derived<PillarValue> = buildOk({
|
||||
value,
|
||||
coverage,
|
||||
confidence,
|
||||
provenance: {
|
||||
inputs: [id],
|
||||
source: "live",
|
||||
windowDays: 28,
|
||||
computedAt: NOW.toISOString(),
|
||||
},
|
||||
});
|
||||
return { id, domain: DOMAIN_BY_PILLAR[id], result };
|
||||
}
|
||||
|
||||
/** A report built through the real composite, not a hand-written envelope. */
|
||||
function report(
|
||||
pillars: ScorePillarResult[],
|
||||
available = pillars.map((p) => p.id),
|
||||
): HealthScoreReport {
|
||||
const composite = computeComposite({
|
||||
pillars,
|
||||
availablePillars: available,
|
||||
asOf: NOW,
|
||||
});
|
||||
return {
|
||||
composite,
|
||||
pillars,
|
||||
delta: null,
|
||||
deltaReason: "no_previous_window",
|
||||
scoreVersion: 2,
|
||||
weightGoal: { status: "insufficient" } as HealthScoreReport["weightGoal"],
|
||||
algorithmNotice: null,
|
||||
};
|
||||
}
|
||||
|
||||
const HEALTHY = [
|
||||
pillar("BLOOD_PRESSURE", 88),
|
||||
pillar("SLEEP", 74),
|
||||
pillar("ADIPOSITY", 66),
|
||||
];
|
||||
|
||||
describe("stored bands", () => {
|
||||
it("matches the band type the column actually accepts", () => {
|
||||
// The column is a Postgres enum, so a band the composite can produce and
|
||||
// the type does not carry is a constraint violation on a write path that
|
||||
// swallows its own failures — the score would keep rendering and the day
|
||||
// would silently go unrecorded. Both ends are literals: the generated
|
||||
// enum on one side, the list the writer uses on the other.
|
||||
expect(Object.values(HealthScoreBand).sort()).toEqual(
|
||||
[...SCORE_BANDS].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("lists every band the composite can produce, and nothing else", () => {
|
||||
const produced = new Set<string>();
|
||||
for (const score of [95, 85, 65, 55, 40, 20, 0]) {
|
||||
const built = report([
|
||||
pillar("BLOOD_PRESSURE", score),
|
||||
pillar("SLEEP", score),
|
||||
pillar("ADIPOSITY", score),
|
||||
]);
|
||||
if (built.composite.status === "ok")
|
||||
produced.add(built.composite.value.band);
|
||||
}
|
||||
expect([...produced].sort()).toEqual([...SCORE_BANDS].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe("input fingerprint", () => {
|
||||
const base = {
|
||||
scoreVersion: 2,
|
||||
composition: ["BLOOD_PRESSURE", "SLEEP", "ADIPOSITY"] as ScorePillarId[],
|
||||
pillars: HEALTHY,
|
||||
};
|
||||
|
||||
it("is stable for the same standing", () => {
|
||||
expect(healthScoreInputFingerprint(base)).toBe(
|
||||
healthScoreInputFingerprint({ ...base, pillars: [...HEALTHY].reverse() }),
|
||||
);
|
||||
});
|
||||
|
||||
it("moves when the algorithm version moves", () => {
|
||||
expect(healthScoreInputFingerprint({ ...base, scoreVersion: 3 })).not.toBe(
|
||||
healthScoreInputFingerprint(base),
|
||||
);
|
||||
});
|
||||
|
||||
it("moves when the composition narrows", () => {
|
||||
const narrowed = {
|
||||
...base,
|
||||
composition: ["BLOOD_PRESSURE", "SLEEP"] as ScorePillarId[],
|
||||
};
|
||||
expect(healthScoreInputFingerprint(narrowed)).not.toBe(
|
||||
healthScoreInputFingerprint(base),
|
||||
);
|
||||
});
|
||||
|
||||
it("moves when a different pillar set produces the same numbers", () => {
|
||||
// Same count, same scores, same order, same scoring modes — only WHICH
|
||||
// pillars counted differs. Without the pillar id in the hash this is the
|
||||
// case that reads as an unchanged standing, and it is exactly the change
|
||||
// a configuration edit makes.
|
||||
const swapped = {
|
||||
...base,
|
||||
composition: ["GLYCAEMIA", "SLEEP", "ADIPOSITY"] as ScorePillarId[],
|
||||
pillars: [
|
||||
pillar("GLYCAEMIA", 88, { deltaIdentity: "shared" }),
|
||||
pillar("SLEEP", 74, { deltaIdentity: "shared" }),
|
||||
pillar("ADIPOSITY", 66, { deltaIdentity: "shared" }),
|
||||
],
|
||||
};
|
||||
const original = {
|
||||
...base,
|
||||
pillars: [
|
||||
pillar("BLOOD_PRESSURE", 88, { deltaIdentity: "shared" }),
|
||||
pillar("SLEEP", 74, { deltaIdentity: "shared" }),
|
||||
pillar("ADIPOSITY", 66, { deltaIdentity: "shared" }),
|
||||
],
|
||||
};
|
||||
expect(healthScoreInputFingerprint(swapped)).not.toBe(
|
||||
healthScoreInputFingerprint(original),
|
||||
);
|
||||
});
|
||||
|
||||
it("moves when a counted pillar's score moves", () => {
|
||||
const shifted = {
|
||||
...base,
|
||||
pillars: [pillar("BLOOD_PRESSURE", 87), HEALTHY[1], HEALTHY[2]],
|
||||
};
|
||||
expect(healthScoreInputFingerprint(shifted)).not.toBe(
|
||||
healthScoreInputFingerprint(base),
|
||||
);
|
||||
});
|
||||
|
||||
it("moves when a pillar's scoring mode changes under an unchanged score", () => {
|
||||
// The number is identical; what produced it is not. Without the scoring
|
||||
// identity in the hash the two standings would be indistinguishable, and
|
||||
// the seam that a scoring-mode change deserves would never be drawn.
|
||||
const remoded = {
|
||||
...base,
|
||||
pillars: [
|
||||
pillar("BLOOD_PRESSURE", 88, { deltaIdentity: "bp:personal-target" }),
|
||||
HEALTHY[1],
|
||||
HEALTHY[2],
|
||||
],
|
||||
};
|
||||
expect(healthScoreInputFingerprint(remoded)).not.toBe(
|
||||
healthScoreInputFingerprint(base),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores a pillar the composite did not count", () => {
|
||||
const withSpectator = {
|
||||
...base,
|
||||
pillars: [...HEALTHY, pillar("ACTIVITY", 12)],
|
||||
};
|
||||
expect(healthScoreInputFingerprint(withSpectator)).toBe(
|
||||
healthScoreInputFingerprint(base),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("building the row", () => {
|
||||
it("records the composite, the band, and every counted pillar", () => {
|
||||
const built = report(HEALTHY);
|
||||
const draft = buildHealthScoreRecord(built, {
|
||||
timezone: "Europe/Berlin",
|
||||
now: NOW,
|
||||
});
|
||||
expect(draft).not.toBeNull();
|
||||
expect(built.composite.status).toBe("ok");
|
||||
if (built.composite.status !== "ok") throw new Error("unreachable");
|
||||
expect(draft!.composite).toBe(built.composite.value.score);
|
||||
expect(draft!.band).toBe(built.composite.value.band);
|
||||
expect(draft!.composition).toEqual(built.composite.value.composition);
|
||||
expect(draft!.pillarScores).toEqual({
|
||||
BLOOD_PRESSURE: 88,
|
||||
SLEEP: 74,
|
||||
ADIPOSITY: 66,
|
||||
});
|
||||
expect(draft!.inputFingerprint).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("cuts the day on the account's clock, not on UTC", () => {
|
||||
// 23:30 UTC on the 20th is already the 21st in Auckland. A row filed under
|
||||
// the UTC day would put the score on a day the person never saw it.
|
||||
const lateEvening = new Date("2026-08-20T23:30:00.000Z");
|
||||
const built = report(HEALTHY);
|
||||
expect(
|
||||
buildHealthScoreRecord(built, {
|
||||
timezone: "Pacific/Auckland",
|
||||
now: lateEvening,
|
||||
})!.dayKey,
|
||||
).toBe("2026-08-21");
|
||||
expect(
|
||||
buildHealthScoreRecord(built, {
|
||||
timezone: "UTC",
|
||||
now: lateEvening,
|
||||
})!.dayKey,
|
||||
).toBe("2026-08-20");
|
||||
});
|
||||
|
||||
it("leaves out a pillar that was graded but not counted", () => {
|
||||
// ACTIVITY is available and graded, but the composite is built from the
|
||||
// three that were eligible. Storing the spectator would report a pillar
|
||||
// as contributing when it did not.
|
||||
const built = report(
|
||||
[...HEALTHY, pillar("ACTIVITY", 12)],
|
||||
["BLOOD_PRESSURE", "SLEEP", "ADIPOSITY"],
|
||||
);
|
||||
const draft = buildHealthScoreRecord(built, {
|
||||
timezone: "UTC",
|
||||
now: NOW,
|
||||
})!;
|
||||
expect(Object.keys(draft.pillarScores)).not.toContain("ACTIVITY");
|
||||
expect(draft.composition).not.toContain("ACTIVITY");
|
||||
});
|
||||
|
||||
it("records nothing when the composite did not resolve", () => {
|
||||
// Two domains: below the breadth floor, so there is no number. Absence has
|
||||
// to reach the table as absence — a stored 0 would be a day the person is
|
||||
// told they scored zero.
|
||||
const thin = report([pillar("ACTIVITY", 90), pillar("WELLBEING", 80)]);
|
||||
expect(thin.composite.status).toBe("insufficient");
|
||||
expect(
|
||||
buildHealthScoreRecord(thin, { timezone: "UTC", now: NOW }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("carries the configuration version and its change date through", () => {
|
||||
const changedAt = new Date("2026-08-19T09:00:00.000Z");
|
||||
const draft = buildHealthScoreRecord(report(HEALTHY), {
|
||||
timezone: "UTC",
|
||||
now: NOW,
|
||||
configVersion: 4,
|
||||
configChangedAt: changedAt,
|
||||
})!;
|
||||
expect(draft.configVersion).toBe(4);
|
||||
expect(draft.configChangedAt).toEqual(changedAt);
|
||||
});
|
||||
|
||||
it("defaults the configuration columns to absent, never to a made-up version", () => {
|
||||
const draft = buildHealthScoreRecord(report(HEALTHY), {
|
||||
timezone: "UTC",
|
||||
now: NOW,
|
||||
})!;
|
||||
expect(draft.configVersion).toBeNull();
|
||||
expect(draft.configChangedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("writing the row", () => {
|
||||
/**
|
||||
* A delegate that answers `createMany` and nothing else. Reaching for
|
||||
* `upsert` or `update` throws here rather than quietly restating a day, so
|
||||
* the never-restate promise is a compile-and-run property of this file and
|
||||
* not a comment.
|
||||
*/
|
||||
function delegate(count: number) {
|
||||
const createMany = vi.fn(async (args: { skipDuplicates?: boolean }) => {
|
||||
void args;
|
||||
return { count };
|
||||
});
|
||||
return {
|
||||
db: { healthScoreRecord: { createMany } } as never,
|
||||
createMany,
|
||||
};
|
||||
}
|
||||
|
||||
it("inserts with a conflict that yields, and never updates", async () => {
|
||||
const { db, createMany } = delegate(1);
|
||||
const outcome = await recordHealthScore(db, "user-1", report(HEALTHY), {
|
||||
timezone: "UTC",
|
||||
now: NOW,
|
||||
});
|
||||
expect(outcome).toBe("written");
|
||||
expect(createMany).toHaveBeenCalledTimes(1);
|
||||
expect(createMany.mock.calls[0][0]).toMatchObject({ skipDuplicates: true });
|
||||
});
|
||||
|
||||
it("reports the day as already recorded when the insert yielded", async () => {
|
||||
const { db } = delegate(0);
|
||||
expect(
|
||||
await recordHealthScore(db, "user-1", report(HEALTHY), {
|
||||
timezone: "UTC",
|
||||
now: NOW,
|
||||
}),
|
||||
).toBe("already_recorded");
|
||||
});
|
||||
|
||||
it("never touches the database when there is no score to record", async () => {
|
||||
const { db, createMany } = delegate(1);
|
||||
const thin = report([pillar("ACTIVITY", 90), pillar("WELLBEING", 80)]);
|
||||
expect(
|
||||
await recordHealthScore(db, "user-1", thin, {
|
||||
timezone: "UTC",
|
||||
now: NOW,
|
||||
}),
|
||||
).toBe("no_score");
|
||||
expect(createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a failed write as failed, and does not take the read down", async () => {
|
||||
const db = {
|
||||
healthScoreRecord: {
|
||||
createMany: vi.fn(async () => {
|
||||
throw new Error("connection reset");
|
||||
}),
|
||||
},
|
||||
} as never;
|
||||
await expect(
|
||||
recordHealthScore(db, "user-1", report(HEALTHY), {
|
||||
timezone: "UTC",
|
||||
now: NOW,
|
||||
}),
|
||||
).resolves.toBe("failed");
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
import { computeUserHealthScore } from "./reader";
|
||||
import { computeAndRecordUserHealthScore } from "./record";
|
||||
import type { CompositeValue } from "./types";
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
@@ -51,7 +51,7 @@ export async function computeHealthScoreDerived(
|
||||
bpAt(new Date(now.getTime() - 7 * DAY_MS)),
|
||||
bpAt(new Date(now.getTime() - 14 * DAY_MS)),
|
||||
]);
|
||||
const report = await computeUserHealthScore({
|
||||
const report = await computeAndRecordUserHealthScore({
|
||||
userId,
|
||||
now,
|
||||
profile: {
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Writing the health score down.
|
||||
*
|
||||
* The score is computed live, every time, from the rows standing behind it at
|
||||
* that moment. That is the right thing for today and it is why nothing older
|
||||
* than today survives: correct a reading from last month, finish a sync that
|
||||
* had stalled, or simply add the fourteenth night of sleep that made a pillar
|
||||
* eligible, and the score the person saw back then can no longer be
|
||||
* reproduced. There is nothing dishonest about that, but it does mean the
|
||||
* "history" a surface can offer today is two numbers computed seconds apart.
|
||||
*
|
||||
* So each local day's score is written down once, the first time it is
|
||||
* computed, and then left alone. Three properties follow, and all three are
|
||||
* deliberate:
|
||||
*
|
||||
* * **Append-only.** A second computation of the same local day finds the
|
||||
* row and does nothing. Not an upsert with a cleverer conflict target,
|
||||
* not a "refresh if newer" — the number the person saw stays the number
|
||||
* they saw. Restating it would make the stored series agree with whatever
|
||||
* the data happens to look like now, which is precisely the property that
|
||||
* makes it worth nothing.
|
||||
* * **Never authoritative.** No surface reads this instead of computing.
|
||||
* The record answers questions about past days; today's number always
|
||||
* comes from today's rows.
|
||||
* * **Never fatal.** The write hangs off a read path. A score that renders
|
||||
* must not fail because a bookkeeping insert did, so the failure is
|
||||
* annotated onto the wide event and swallowed. The integration round trip
|
||||
* is what proves the write actually happens; a unit test that only
|
||||
* watches this function return would be watching it succeed at nothing.
|
||||
*
|
||||
* Server-only — reaches for the Prisma client.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { prisma as globalPrisma } from "@/lib/db";
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
import { computeUserHealthScore, type UserHealthScoreInput } from "./reader";
|
||||
import type {
|
||||
HealthScoreReport,
|
||||
ScoreBand,
|
||||
ScorePillarId,
|
||||
ScorePillarResult,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* The bands a stored row may carry, and the same closed set the migration's
|
||||
* CHECK constraint spells out.
|
||||
*
|
||||
* Pinned to {@link ScoreBand} in BOTH directions at compile time: `satisfies`
|
||||
* rejects a member that is not a band, and `_BandsAreExhaustive` fails to
|
||||
* compile if a band is ever added to the union without being added here. A
|
||||
* one-directional check would let a fourth band reach a column that refuses
|
||||
* it, which is a runtime constraint violation on a write path that is
|
||||
* supposed to be unable to fail loudly.
|
||||
*/
|
||||
export const SCORE_BANDS = [
|
||||
"green",
|
||||
"yellow",
|
||||
"red",
|
||||
] as const satisfies readonly ScoreBand[];
|
||||
|
||||
type _BandsAreExhaustive =
|
||||
Exclude<ScoreBand, (typeof SCORE_BANDS)[number]> extends never ? true : never;
|
||||
const _bandsAreExhaustive: _BandsAreExhaustive = true;
|
||||
void _bandsAreExhaustive;
|
||||
|
||||
/** Fingerprint format version. Bump when the covered set below changes. */
|
||||
const FINGERPRINT_VERSION = 1;
|
||||
|
||||
/** The row a computed report turns into, before it reaches the database. */
|
||||
export interface HealthScoreRecordDraft {
|
||||
dayKey: string;
|
||||
timezone: string;
|
||||
composite: number;
|
||||
band: ScoreBand;
|
||||
scoreVersion: number;
|
||||
composition: ScorePillarId[];
|
||||
pillarScores: Record<string, number>;
|
||||
inputFingerprint: string;
|
||||
configVersion: number | null;
|
||||
configChangedAt: Date | null;
|
||||
computedAt: Date;
|
||||
}
|
||||
|
||||
/** What the caller knows that the report itself does not carry. */
|
||||
export interface HealthScoreRecordContext {
|
||||
/** The account's IANA zone — the clock the local day is cut on. */
|
||||
timezone: string;
|
||||
/** The instant the score was computed for. */
|
||||
now: Date;
|
||||
/**
|
||||
* The account's health-score configuration version in force for this
|
||||
* computation, and when it last changed. Both stay `null` until the
|
||||
* per-user configuration exists; the parameters are here so threading them
|
||||
* through later is a caller change and not a migration.
|
||||
*/
|
||||
configVersion?: number | null;
|
||||
configChangedAt?: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every pillar that actually counted, in the composition's own order, with
|
||||
* the score and the scoring-mode identity that produced it.
|
||||
*
|
||||
* Reads the composition rather than the pillar list, because the pillar list
|
||||
* carries pillars that were graded and then not counted (insufficient
|
||||
* coverage, module off). Storing those would make the fingerprint move for
|
||||
* reasons the composite never saw.
|
||||
*/
|
||||
function countedPillars(
|
||||
composition: readonly ScorePillarId[],
|
||||
pillars: readonly ScorePillarResult[],
|
||||
): Array<{ id: ScorePillarId; score: number; deltaIdentity: string }> {
|
||||
const byId = new Map(pillars.map((pillar) => [pillar.id, pillar]));
|
||||
return composition.flatMap((id) => {
|
||||
const pillar = byId.get(id);
|
||||
if (!pillar || pillar.result.status !== "ok") return [];
|
||||
return [
|
||||
{
|
||||
id,
|
||||
score: pillar.result.value.score,
|
||||
deltaIdentity: pillar.result.value.deltaIdentity,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA-256 over exactly what the stored composite and band depend on.
|
||||
*
|
||||
* The covered set is the whole point, so it is spelled out rather than
|
||||
* "hash the report": the algorithm version, and then one ordered triple per
|
||||
* counted pillar — its id, its score, and the scoring mode that produced that
|
||||
* score. Drop any one of those and two genuinely different standings collapse
|
||||
* onto one fingerprint, which is a key missing a dimension its value depends
|
||||
* on, a defect class this codebase has shipped before.
|
||||
*
|
||||
* The composition is not hashed separately. It IS the ordered list of ids in
|
||||
* those triples, and an earlier draft carried it twice: a key that no input
|
||||
* could ever make disagree with its neighbour, and therefore a key no test
|
||||
* could make fail. A hash term nothing can move is not defence in depth, it
|
||||
* is decoration on a guard.
|
||||
*
|
||||
* What is deliberately NOT covered: the account's configuration version. A
|
||||
* configuration decides the composition, and the composition is already here.
|
||||
* A configuration change that leaves the composition alone leaves the score
|
||||
* alone too, and a fingerprint that moved anyway would report a break that
|
||||
* did not happen.
|
||||
*/
|
||||
export function healthScoreInputFingerprint(input: {
|
||||
scoreVersion: number;
|
||||
composition: readonly ScorePillarId[];
|
||||
pillars: readonly ScorePillarResult[];
|
||||
}): string {
|
||||
const counted = countedPillars(input.composition, input.pillars);
|
||||
const canonical = JSON.stringify({
|
||||
v: FINGERPRINT_VERSION,
|
||||
scoreVersion: input.scoreVersion,
|
||||
pillars: counted.map((pillar) => [
|
||||
pillar.id,
|
||||
pillar.score,
|
||||
pillar.deltaIdentity,
|
||||
]),
|
||||
});
|
||||
return createHash("sha256").update(canonical).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a computed report into the row it would be stored as, or `null` when
|
||||
* there is nothing to record.
|
||||
*
|
||||
* `null` means the composite did not resolve — too few domains, no
|
||||
* physiological pillar, a read that failed. That is honest absence and it is
|
||||
* NOT a row: a stored zero, or a stored "insufficient" placeholder, would put
|
||||
* a value on a day that never had one, and every reader downstream would have
|
||||
* to know to disbelieve it.
|
||||
*
|
||||
* Pure, so the fingerprint and the shape can be tested without a database.
|
||||
*/
|
||||
export function buildHealthScoreRecord(
|
||||
report: HealthScoreReport,
|
||||
context: HealthScoreRecordContext,
|
||||
): HealthScoreRecordDraft | null {
|
||||
if (report.composite.status !== "ok") return null;
|
||||
const composite = report.composite.value;
|
||||
const counted = countedPillars(composite.composition, report.pillars);
|
||||
if (counted.length === 0) return null;
|
||||
|
||||
return {
|
||||
dayKey: userDayKey(context.now, context.timezone),
|
||||
timezone: context.timezone,
|
||||
composite: composite.score,
|
||||
band: composite.band,
|
||||
scoreVersion: composite.scoreVersion,
|
||||
composition: [...composite.composition],
|
||||
pillarScores: Object.fromEntries(
|
||||
counted.map((pillar) => [pillar.id, pillar.score]),
|
||||
),
|
||||
inputFingerprint: healthScoreInputFingerprint({
|
||||
scoreVersion: composite.scoreVersion,
|
||||
composition: composite.composition,
|
||||
pillars: report.pillars,
|
||||
}),
|
||||
configVersion: context.configVersion ?? null,
|
||||
configChangedAt: context.configChangedAt ?? null,
|
||||
computedAt: context.now,
|
||||
};
|
||||
}
|
||||
|
||||
/** What the write did, so a caller or a test can tell the three apart. */
|
||||
export type HealthScoreRecordOutcome =
|
||||
/** A new day was recorded. */
|
||||
| "written"
|
||||
/** The day already had a record, which was left exactly as it was. */
|
||||
| "already_recorded"
|
||||
/** Nothing to record — the composite did not resolve. */
|
||||
| "no_score"
|
||||
/** The insert failed. Annotated, not thrown. */
|
||||
| "failed";
|
||||
|
||||
type RecordDelegate = Pick<PrismaClient, "healthScoreRecord">;
|
||||
|
||||
/**
|
||||
* Record one local day's score, once.
|
||||
*
|
||||
* `createMany` with `skipDuplicates` compiles to `ON CONFLICT DO NOTHING`
|
||||
* against the `(user_id, day_key)` unique index, so two concurrent requests
|
||||
* on the same day race into the same outcome the second request would have
|
||||
* reached anyway: the first row stands. An `upsert` would have been the
|
||||
* obvious call here and would have been wrong — its update branch is exactly
|
||||
* the restatement this table must not do.
|
||||
*/
|
||||
export async function recordHealthScore(
|
||||
db: RecordDelegate,
|
||||
userId: string,
|
||||
report: HealthScoreReport,
|
||||
context: HealthScoreRecordContext,
|
||||
): Promise<HealthScoreRecordOutcome> {
|
||||
const draft = buildHealthScoreRecord(report, context);
|
||||
if (!draft) return "no_score";
|
||||
|
||||
try {
|
||||
const written = await db.healthScoreRecord.createMany({
|
||||
data: [
|
||||
{
|
||||
userId,
|
||||
dayKey: draft.dayKey,
|
||||
timezone: draft.timezone,
|
||||
composite: draft.composite,
|
||||
band: draft.band,
|
||||
scoreVersion: draft.scoreVersion,
|
||||
composition: draft.composition,
|
||||
pillarScores: draft.pillarScores as Prisma.InputJsonValue,
|
||||
inputFingerprint: draft.inputFingerprint,
|
||||
configVersion: draft.configVersion,
|
||||
configChangedAt: draft.configChangedAt,
|
||||
computedAt: draft.computedAt,
|
||||
},
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
if (written.count === 0) return "already_recorded";
|
||||
annotate({
|
||||
action: { name: "score.record.write" },
|
||||
meta: {
|
||||
dayKey: draft.dayKey,
|
||||
scoreVersion: draft.scoreVersion,
|
||||
pillars: draft.composition.length,
|
||||
},
|
||||
});
|
||||
return "written";
|
||||
} catch (err) {
|
||||
// A failed bookkeeping write must not take a rendered score down with it,
|
||||
// but it must not pass for silence either: the warning rides the wide
|
||||
// event so an operator sees the day that went unrecorded.
|
||||
getEvent()?.addWarning(
|
||||
`health score record write failed for ${draft.dayKey}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return "failed";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the account's health score and record the day it was computed for.
|
||||
*
|
||||
* One entry point rather than a write bolted onto each of the surfaces that
|
||||
* compute a score. Three callers each remembering to record is three places
|
||||
* that can independently stop remembering, and the one that forgets leaves a
|
||||
* gap in the series that nothing reports.
|
||||
*/
|
||||
export async function computeAndRecordUserHealthScore(
|
||||
input: UserHealthScoreInput,
|
||||
record?: Pick<HealthScoreRecordContext, "configVersion" | "configChangedAt">,
|
||||
): Promise<HealthScoreReport> {
|
||||
const report = await computeUserHealthScore(input);
|
||||
await recordHealthScore(input.prisma ?? globalPrisma, input.userId, report, {
|
||||
timezone: input.profile.timezone,
|
||||
now: input.now,
|
||||
configVersion: record?.configVersion ?? null,
|
||||
configChangedAt: record?.configChangedAt ?? null,
|
||||
});
|
||||
return report;
|
||||
}
|
||||
@@ -77,7 +77,7 @@ import {
|
||||
buildMedsTodayBlock,
|
||||
type MedsTodayBlock,
|
||||
} from "@/lib/dashboard/meds-today";
|
||||
import { computeUserHealthScore } from "@/lib/analytics/score/reader";
|
||||
import { computeAndRecordUserHealthScore } from "@/lib/analytics/score/record";
|
||||
import {
|
||||
buildDashboardBands as buildTargetBands,
|
||||
type DashboardTargetBands,
|
||||
@@ -742,7 +742,7 @@ async function buildExtras(
|
||||
// prior-week delta values). Closes the dashboard-vs-insights divergence.
|
||||
const scoreSourcePriority = user.sourcePriorityJson ?? null;
|
||||
const scoreResult = await time("healthScore", () =>
|
||||
computeUserHealthScore({
|
||||
computeAndRecordUserHealthScore({
|
||||
prisma,
|
||||
userId: user.id,
|
||||
now,
|
||||
|
||||
@@ -54,6 +54,12 @@ export const WIPE_MODELS = [
|
||||
// record and nothing else holds it, so a wipe that leaves it behind leaves
|
||||
// behind an account's activity pattern.
|
||||
"IntradayCumulativeProfile",
|
||||
// The health score as it was shown, one row per local day. Not a cache of
|
||||
// the live computation — nothing can rebuild a past day once the readings
|
||||
// behind it have moved — so leaving it behind leaves behind a record of how
|
||||
// the account was doing, day by day, after a confirmation that said
|
||||
// otherwise.
|
||||
"HealthScoreRecord",
|
||||
"MeasurementReminder",
|
||||
|
||||
// ── Medication ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -204,6 +204,7 @@ function makePrisma() {
|
||||
},
|
||||
]),
|
||||
},
|
||||
healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// Left unmocked on purpose: `buildProfileBackupSection` runs for real
|
||||
// against these, so the assertions below exercise the builder rather than
|
||||
// a stand-in that would agree with whatever the payload happened to do.
|
||||
|
||||
@@ -103,6 +103,12 @@ export const BACKED_UP_MODELS = [
|
||||
"CustomMetric",
|
||||
"CustomMetricEntry",
|
||||
"CorrelationPattern",
|
||||
// Reads like a derived tier and is not one. The live computation always
|
||||
// answers for today from today's rows, so once the readings behind a past
|
||||
// day have been corrected, re-synced or simply grown, nothing can reproduce
|
||||
// what that day said. The row is the only copy of the number the account
|
||||
// actually saw, which is exactly the test for BACKED_UP rather than DERIVED.
|
||||
"HealthScoreRecord",
|
||||
|
||||
// ── Activity and nutrition ────────────────────────────────────────────────
|
||||
"Workout",
|
||||
@@ -157,6 +163,7 @@ export const BACKUP_WRITER_FILES: readonly string[] = [
|
||||
"src/lib/export/records-backup.ts",
|
||||
"src/lib/export/profile-backup.ts",
|
||||
"src/lib/export/intraday-profile-backup.ts",
|
||||
"src/lib/export/health-score-backup.ts",
|
||||
"src/lib/cycle/backup.ts",
|
||||
];
|
||||
|
||||
@@ -164,6 +171,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [
|
||||
"src/app/api/admin/backups/[id]/restore/route.ts",
|
||||
"src/lib/export/profile-backup.ts",
|
||||
"src/lib/export/intraday-profile-backup.ts",
|
||||
"src/lib/export/health-score-backup.ts",
|
||||
"src/lib/cycle/backup.ts",
|
||||
];
|
||||
|
||||
@@ -206,6 +214,7 @@ export const TWO_ENDED_MODELS: readonly string[] = [
|
||||
"CustomMetric",
|
||||
"CustomMetricEntry",
|
||||
"CorrelationPattern",
|
||||
"HealthScoreRecord",
|
||||
"Workout",
|
||||
"NutrientIntakeDay",
|
||||
"InboundDocument",
|
||||
|
||||
@@ -31,6 +31,12 @@ import {
|
||||
type ProfileBackupCounts,
|
||||
type ProfileBackupSection,
|
||||
} from "@/lib/export/profile-backup";
|
||||
import {
|
||||
buildHealthScoreBackupSection,
|
||||
countHealthScoreBackupSection,
|
||||
type HealthScoreBackupCounts,
|
||||
type HealthScoreBackupSection,
|
||||
} from "@/lib/export/health-score-backup";
|
||||
import {
|
||||
buildIntradayProfileBackupSection,
|
||||
countIntradayProfileBackupSection,
|
||||
@@ -42,7 +48,8 @@ export interface FullBackupCounts
|
||||
extends
|
||||
RecordsBackupCounts,
|
||||
ProfileBackupCounts,
|
||||
IntradayProfileBackupCounts {
|
||||
IntradayProfileBackupCounts,
|
||||
HealthScoreBackupCounts {
|
||||
measurements: number;
|
||||
medications: number;
|
||||
intakeEvents: number;
|
||||
@@ -117,6 +124,7 @@ export async function buildFullBackupPayload(
|
||||
records,
|
||||
profile,
|
||||
intradayProfiles,
|
||||
healthScoreRecords,
|
||||
nutrientDays,
|
||||
] = await Promise.all([
|
||||
disasterRecovery
|
||||
@@ -225,6 +233,13 @@ export async function buildFullBackupPayload(
|
||||
buildIntradayProfileBackupSection(prisma, userId, {
|
||||
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
|
||||
}),
|
||||
// The score as it was SHOWN on each local day. Reads like a derived tier
|
||||
// and is not one: today's number always comes from today's rows, so once
|
||||
// the readings behind a past day have moved, nothing can recompute what
|
||||
// that day said. The row is the only copy.
|
||||
buildHealthScoreBackupSection(prisma, userId, {
|
||||
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
|
||||
}),
|
||||
// Nutrient day totals were absent from every export path, which
|
||||
// contradicted the schema's own reason for denormalising the unit column
|
||||
// ("rows stay self-describing in exports even if the catalog ever drifts").
|
||||
@@ -250,6 +265,7 @@ export async function buildFullBackupPayload(
|
||||
const recordsSection: RecordsBackupSection = records;
|
||||
const profileSection: ProfileBackupSection = profile;
|
||||
const intradaySection: IntradayProfileBackupSection = intradayProfiles;
|
||||
const healthScoreSection: HealthScoreBackupSection = healthScoreRecords;
|
||||
|
||||
const payload = {
|
||||
schemaVersion: BACKUP_SCHEMA_VERSION,
|
||||
@@ -396,6 +412,7 @@ export async function buildFullBackupPayload(
|
||||
...recordsSection,
|
||||
...profileSection,
|
||||
...intradaySection,
|
||||
...healthScoreSection,
|
||||
nutrientDays: nutrientDays.map((n) => ({
|
||||
day: n.day,
|
||||
nutrient: n.nutrient,
|
||||
@@ -418,6 +435,7 @@ export async function buildFullBackupPayload(
|
||||
...countRecordsBackupSection(records),
|
||||
...countProfileBackupSection(profile),
|
||||
...countIntradayProfileBackupSection(intradayProfiles),
|
||||
...countHealthScoreBackupSection(healthScoreRecords),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* The stored health score days, with both backup ends in one file.
|
||||
*
|
||||
* `HealthScoreRecord` looks derived and is not. The live computation answers
|
||||
* for today from today's rows, so a day whose readings have since been
|
||||
* corrected, re-synced or simply extended cannot be recomputed as it stood;
|
||||
* the row is the only copy of the number the account actually saw. Classifying
|
||||
* it `DERIVED` and leaving it out would produce a restore that comes back with
|
||||
* a blank history and reports success, which is the shape of defect the
|
||||
* nutrient day totals, the custom mood tags and the custom cycle symptoms each
|
||||
* shipped in turn.
|
||||
*
|
||||
* The builder and the restore live together deliberately, the way
|
||||
* `intraday-profile-backup.ts` and `src/lib/cycle/backup.ts` do: a reader
|
||||
* asking "is this carried at both ends?" answers it in one file, and a reader
|
||||
* who greps only the restore ROUTE gets a false negative, because the route
|
||||
* delegates.
|
||||
*
|
||||
* Nothing here is encrypted, so there is no portable/disaster-recovery split
|
||||
* on the values. The only difference between the two purposes is identity: a
|
||||
* canonical disaster-recovery payload carries the row id and its timestamps so
|
||||
* the row comes back as itself, a portable export carries the day alone.
|
||||
*/
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
import type { ScoreBand } from "@/lib/analytics/score/types";
|
||||
|
||||
export interface HealthScoreBackupOptions {
|
||||
purpose?: "portable-export" | "disaster-recovery";
|
||||
}
|
||||
|
||||
/** One local day's score, exactly as it was shown. */
|
||||
export interface HealthScoreRecordBackupEntry {
|
||||
/** Present in canonical DR payloads so the row keeps a stable identity. */
|
||||
id?: string;
|
||||
/** Local calendar day (`YYYY-MM-DD`) in `timezone`. */
|
||||
dayKey: string;
|
||||
/**
|
||||
* The zone the day was cut on. Carried for the same reason the intraday
|
||||
* curves carry theirs: an account that moved zones has days of different
|
||||
* lengths behind it, and a restore that guessed would make them look
|
||||
* comparable.
|
||||
*/
|
||||
timezone: string;
|
||||
composite: number;
|
||||
band: ScoreBand;
|
||||
scoreVersion: number;
|
||||
composition: string[];
|
||||
/** Each counted pillar's own score, keyed by pillar id. */
|
||||
pillarScores: Record<string, number>;
|
||||
inputFingerprint: string;
|
||||
configVersion: number | null;
|
||||
configChangedAt: string | null;
|
||||
computedAt: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface HealthScoreBackupSection {
|
||||
healthScoreRecords: HealthScoreRecordBackupEntry[];
|
||||
}
|
||||
|
||||
export interface HealthScoreBackupCounts {
|
||||
healthScoreRecords: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the stored score days as JSON-safe entries.
|
||||
*
|
||||
* `pillarScores` arrives as an opaque `JsonValue`; it is narrowed here rather
|
||||
* than cast, so a row holding something other than a flat id-to-number map
|
||||
* reaches the payload as an empty map instead of as a shape the restore's
|
||||
* schema would reject at the worst possible moment.
|
||||
*/
|
||||
function readPillarScores(value: Prisma.JsonValue): Record<string, number> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
const out: Record<string, number> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (typeof entry === "number") out[key] = entry;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the stored-score slice of a user's full backup.
|
||||
*
|
||||
* Takes the delegate it uses rather than a whole `PrismaClient`, matching the
|
||||
* other section builders, so the route's global client and the worker's local
|
||||
* one share one read.
|
||||
*/
|
||||
export async function buildHealthScoreBackupSection(
|
||||
prisma: Pick<PrismaClient, "healthScoreRecord">,
|
||||
userId: string,
|
||||
options: HealthScoreBackupOptions = {},
|
||||
): Promise<HealthScoreBackupSection> {
|
||||
const disasterRecovery = options.purpose === "disaster-recovery";
|
||||
|
||||
// The table carries no tombstone — a day is written once and never deleted
|
||||
// short of a wipe — so both purposes read the same set.
|
||||
const rows = await prisma.healthScoreRecord.findMany({
|
||||
where: { userId },
|
||||
orderBy: { dayKey: "desc" },
|
||||
});
|
||||
|
||||
return {
|
||||
healthScoreRecords: rows.map((row) => ({
|
||||
...(disasterRecovery
|
||||
? { id: row.id, createdAt: row.createdAt.toISOString() }
|
||||
: {}),
|
||||
dayKey: row.dayKey,
|
||||
timezone: row.timezone,
|
||||
composite: row.composite,
|
||||
band: row.band,
|
||||
scoreVersion: row.scoreVersion,
|
||||
composition: row.composition,
|
||||
pillarScores: readPillarScores(row.pillarScores),
|
||||
inputFingerprint: row.inputFingerprint,
|
||||
configVersion: row.configVersion,
|
||||
configChangedAt: row.configChangedAt?.toISOString() ?? null,
|
||||
computedAt: row.computedAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Row counts for the audit trail, mirroring the other section counters. */
|
||||
export function countHealthScoreBackupSection(
|
||||
section: HealthScoreBackupSection,
|
||||
): HealthScoreBackupCounts {
|
||||
return { healthScoreRecords: section.healthScoreRecords.length };
|
||||
}
|
||||
|
||||
/** Counts the score-record restore wiped, for the audit trail. */
|
||||
export interface HealthScoreRestoreCleared {
|
||||
healthScoreRecords: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The slice of a parsed backup this restore consumes.
|
||||
*
|
||||
* Required rather than optional: `BackupPayload` defaults it, so every caller
|
||||
* already satisfies this, and a caller that stops satisfying it fails to
|
||||
* compile instead of passing `undefined` into a loop that iterates zero times
|
||||
* and reports success.
|
||||
*/
|
||||
export interface HealthScoreRestoreInput {
|
||||
healthScoreRecords: HealthScoreRecordBackupEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-create the account's stored score days.
|
||||
*
|
||||
* Delete-then-recreate inside the caller's transaction, matching every other
|
||||
* section. Note the asymmetry with the live write path next door: THAT one
|
||||
* yields on conflict, because a second computation of a day it already holds
|
||||
* must change nothing. This one does not, because the file is the authority
|
||||
* here and a day it carries twice is a corrupt file, not a race. Two entries
|
||||
* claiming the same local day therefore throw with a sentence rather than
|
||||
* letting `skipDuplicates` pick a winner and report success.
|
||||
*/
|
||||
export async function restoreHealthScoreData(
|
||||
tx: Prisma.TransactionClient,
|
||||
ownerId: string,
|
||||
payload: HealthScoreRestoreInput,
|
||||
): Promise<HealthScoreRestoreCleared> {
|
||||
const cleared = await tx.healthScoreRecord.deleteMany({
|
||||
where: { userId: ownerId },
|
||||
});
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const record of payload.healthScoreRecords) {
|
||||
if (seen.has(record.dayKey)) {
|
||||
throw new Error(
|
||||
`Duplicate health score record for ${record.dayKey}. One account has ` +
|
||||
"at most one score per local day, and picking a winner here would " +
|
||||
"silently discard the other reading of that day.",
|
||||
);
|
||||
}
|
||||
seen.add(record.dayKey);
|
||||
}
|
||||
|
||||
if (payload.healthScoreRecords.length > 0) {
|
||||
await tx.healthScoreRecord.createMany({
|
||||
data: payload.healthScoreRecords.map((record) => ({
|
||||
...(record.id ? { id: record.id } : {}),
|
||||
userId: ownerId,
|
||||
dayKey: record.dayKey,
|
||||
timezone: record.timezone,
|
||||
composite: record.composite,
|
||||
band: record.band,
|
||||
scoreVersion: record.scoreVersion,
|
||||
composition: record.composition,
|
||||
pillarScores: record.pillarScores as Prisma.InputJsonValue,
|
||||
inputFingerprint: record.inputFingerprint,
|
||||
configVersion: record.configVersion,
|
||||
configChangedAt: record.configChangedAt
|
||||
? new Date(record.configChangedAt)
|
||||
: null,
|
||||
computedAt: new Date(record.computedAt),
|
||||
...(record.createdAt ? { createdAt: new Date(record.createdAt) } : {}),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return { healthScoreRecords: cleared.count };
|
||||
}
|
||||
@@ -577,6 +577,30 @@ const intradayProfileBackupSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const healthScoreRecordBackupSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).optional(),
|
||||
dayKey: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
// The zone the day was cut on. An account that moved zones has days of
|
||||
// different lengths behind it, and a restore that guessed would make them
|
||||
// look comparable.
|
||||
timezone: z.string().min(1),
|
||||
composite: z.number().int().min(0).max(100),
|
||||
// The same closed set the column's CHECK constraint carries. A band the
|
||||
// schema let through and the database refused would fail the whole
|
||||
// restore transaction on one bad row.
|
||||
band: z.enum(["green", "yellow", "red"]),
|
||||
scoreVersion: z.number().int(),
|
||||
composition: z.array(z.string().min(1)).min(1),
|
||||
pillarScores: z.record(z.string(), z.number()),
|
||||
inputFingerprint: z.string().regex(/^[0-9a-f]{64}$/),
|
||||
configVersion: z.number().int().nullable().default(null),
|
||||
configChangedAt: isoDateTime.nullable().default(null),
|
||||
computedAt: isoDateTime,
|
||||
createdAt: isoDateTime.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const appSettingsBackupSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
@@ -849,6 +873,10 @@ export const backupPayloadSchema = z
|
||||
// The hourly shape of a cumulative day. Defaulted for the same reason as
|
||||
// the pair above: a file written before the table existed carries no key.
|
||||
intradayProfiles: z.array(intradayProfileBackupSchema).default([]),
|
||||
// The score as it was shown, day by day. Defaulted for the same reason as
|
||||
// the sections above: a file written before the table existed carries no
|
||||
// key, and an account whose score never resolved writes [].
|
||||
healthScoreRecords: z.array(healthScoreRecordBackupSchema).default([]),
|
||||
manifest: backupManifestSchema.nullable().default(null),
|
||||
})
|
||||
.passthrough()
|
||||
@@ -913,6 +941,8 @@ export interface BackupSummary {
|
||||
correlationPatterns: number;
|
||||
/** Stored day curves for the cumulative metrics, across every metric. */
|
||||
intradayProfiles: number;
|
||||
/** Local days whose health score was written down as it was shown. */
|
||||
healthScoreRecords: number;
|
||||
}
|
||||
|
||||
export function summarizeBackup(payload: BackupPayload): BackupSummary {
|
||||
@@ -947,6 +977,7 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary {
|
||||
),
|
||||
correlationPatterns: payload.correlationPatterns.length,
|
||||
intradayProfiles: payload.intradayProfiles.length,
|
||||
healthScoreRecords: payload.healthScoreRecords.length,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user