Merge the persisted score record into the composition swap

This commit is contained in:
Marc-André Bombeck committed 2026-07-31 15:57:37 +02:00
commit 0826bd80e6
18 files changed
+1614 -7

No files matched your search

@@ -0,0 +1,63 @@
-- v1.35.0 — the health score as it was shown, one row per account per local day.
--
-- Nothing can rebuild this. 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, the number the person saw on that day is gone
-- unless it was written down. This table is the writing down.
--
-- Two properties are enforced here rather than left to the application:
--
-- * one row per account per local day, so a second computation on the same
-- day cannot mint a rival record of the same day, and the writer's
-- ON CONFLICT DO NOTHING has something to conflict against;
-- * a closed set of bands, as a type rather than as a CHECK. The value the
-- application speaks is lowercase, so the enum carries the same labels and
-- needs no translation layer. A type also keeps the schema-driven wipe
-- seeder working: it reads `pg_enum` for a value it cannot otherwise
-- guess, where a CHECK would have forced a hand-written entry into a
-- fixture whose whole point is not having one.
--
-- The composite is range-checked because it is free to check and a number
-- outside 0-100 would render as a score nobody can read. The day key's format
-- and the composition's non-emptiness are NOT checked here: the writer builds
-- the key through the timezone helper and refuses an empty composition before
-- it inserts, and the restore validates both against the wire schema before
-- the transaction opens. A third copy of those two rules would buy nothing.
--
-- There is deliberately no updated_at column. The row is written once and
-- never rewritten: a later computation under different readings, or under a
-- different configuration, leaves the earlier day exactly as it stood.
CREATE TYPE "health_score_band" AS ENUM ('green', 'yellow', 'red');
CREATE TABLE "health_score_records" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"day_key" VARCHAR(10) NOT NULL,
"timezone" TEXT NOT NULL,
"composite" INTEGER NOT NULL,
"band" "health_score_band" NOT NULL,
"score_version" INTEGER NOT NULL,
"composition" TEXT[] NOT NULL,
"pillar_scores" JSONB NOT NULL,
"input_fingerprint" VARCHAR(64) NOT NULL,
"config_version" INTEGER,
"config_changed_at" TIMESTAMPTZ(3),
"computed_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "health_score_records_pkey" PRIMARY KEY ("id"),
CONSTRAINT "health_score_records_composite_range_check"
CHECK ("composite" >= 0 AND "composite" <= 100)
);
CREATE UNIQUE INDEX "health_score_records_user_id_day_key_key"
ON "health_score_records" ("user_id", "day_key");
CREATE INDEX "health_score_records_user_id_day_key_desc_idx"
ON "health_score_records" ("user_id", "day_key" DESC);
ALTER TABLE "health_score_records"
ADD CONSTRAINT "health_score_records_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
+90
View File
@@ -870,6 +870,10 @@ model User {
customMetrics CustomMetric[]
customMetricEntries CustomMetricEntry[]
correlationPatterns CorrelationPattern[]
// v1.35.0 — one row per local day holding the health score as it was shown
// that day. Append-only: a later recomputation of the same day never
// rewrites the row it finds.
healthScoreRecords HealthScoreRecord[]
// v1.19.0 — illness / condition journal (born-gated by the module system).
// IllnessEpisode is the soft-deleted episode row; IllnessDayLog is the
// per-day timeline mirroring the CycleDayLog shape.
@@ -5074,6 +5078,92 @@ model UserAchievement {
@@map("user_achievements")
}
// ─── Health Score record ──────
/// The three bands a composite can land in, lowercase because that is the
/// value every surface already speaks. Closed on purpose: the column refuses
/// a band no renderer knows.
enum HealthScoreBand {
green
yellow
red
@@map("health_score_band")
}
/// The health score as it was SHOWN to the account on one local day.
///
/// Not a cache and not a derived tier: nothing can rebuild it. The live
/// computation always answers for today from today's rows, so once the rows
/// behind an old day have been edited, re-synced, corrected or simply grown,
/// the number the person actually saw that day is gone unless it was written
/// down. That is what this table is, and it is why the write is append-only:
/// the FIRST score computed on a local day is the one that is kept, and a
/// later computation of the same day leaves the row exactly as it found it.
///
/// It never overrides the live computation either. Every surface keeps
/// reading the live score; this table is read only where a past day is the
/// question.
model HealthScoreRecord {
id String @id @default(cuid())
userId String @map("user_id")
/// The LOCAL calendar day (`YYYY-MM-DD`) the score was computed for, cut in
/// `timezone`. Lexicographically ordered, so a trailing-window read is a
/// range scan.
dayKey String @map("day_key") @db.VarChar(10)
/// The IANA zone the day was cut in. An account that moves zones leaves
/// behind days cut on the old clock; carrying the zone is what lets a
/// reader see that rather than silently compare days of different lengths.
timezone String
/// The composite 0-100 as shown. Integer, matching the composite's own
/// rounding.
composite Int
/// The band as shown. Stored rather than re-derived from `composite`,
/// because the worst pillar can pull the band below the one the mean alone
/// would give, so the number does not determine the band.
band HealthScoreBand
/// Algorithm identity the row was computed under.
scoreVersion Int @map("score_version")
/// Registry-ordered pillar ids that produced the composite. Part of the
/// number's identity: the same value under a different composition is a
/// different measurement.
composition String[]
/// Each counted pillar's own 0-100 score, keyed by pillar id. The composite
/// breaks across a composition change; these stay comparable through one,
/// which is the whole reason they are stored beside it rather than folded
/// away.
pillarScores Json @map("pillar_scores")
/// SHA-256 over exactly what the stored composite and band depend on: the
/// score version, the composition, and every counted pillar's score with
/// its scoring-mode identity. Two rows with the same fingerprint were
/// computed from the same standing; two rows that differ, were not.
inputFingerprint String @db.VarChar(64) @map("input_fingerprint")
/// The account's health-score configuration version in force when the row
/// was written, and when that configuration last changed. The write path
/// takes both as parameters and persists whatever it is given; today
/// nothing supplies them, because the per-user configuration itself lands
/// beside this table and the guard that reads them lands after it. They are
/// here now so that guard does not need a second migration over a table
/// that will already hold rows.
/// @pending-consumer(score delta guard) suppresses the week-over-week delta across a configuration change, so a settings action is never narrated as a health event.
configVersion Int? @map("config_version")
/// @pending-consumer(score delta guard) dates the series break on the first record written after a configuration change.
configChangedAt DateTime? @map("config_changed_at") @db.Timestamptz(3)
/// When the score was computed. Distinct from `dayKey`: the day says which
/// day was scored, this says when the scoring ran.
computedAt DateTime @default(now()) @map("computed_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, dayKey])
@@index([userId, dayKey(sort: Desc)])
@@map("health_score_records")
}
// ─── Host Metrics (admin system-status host-load chart) ──────
/// Per-minute snapshot of host-process load + memory captured by the
@@ -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,
};
},
{
+2 -2
View File
@@ -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");
});
});
+2 -2
View File
@@ -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: {
+308
View File
@@ -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;
}
+2 -2
View File
@@ -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,
+6
View File
@@ -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.
+9
View File
@@ -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",
+19 -1
View File
@@ -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),
},
};
}
+206
View File
@@ -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 };
}
+31
View File
@@ -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,
};
}
@@ -0,0 +1,407 @@
/**
* The persisted health score day, end to end against real Postgres.
*
* A mocked Prisma proves nothing here. Every claim this file makes is about
* what is actually in the table after a real request: that computing a score
* writes the day down, that computing it again under different readings
* leaves the earlier day exactly as it stood, and that the row survives a
* backup and a restore rather than coming back blank while the restore
* reports success.
*
* The three lifecycle paths a new user-scoped table has to join, and where
* each is proved:
*
* - delete-all-data — `tests/integration/settings-data-wipe.test.ts` seeds
* every table from `information_schema` and asserts every model in the
* wipe plan is empty afterwards, so this table is covered there the
* moment it enters the plan. `src/__tests__/data-wipe-completeness.test.ts`
* is what forces it into the plan in the first place.
* - the backup writer — proved below over `buildFullBackupPayload`, which
* is the builder the weekly worker and both export routes share.
* - the restore path — proved below over the real admin restore ROUTE, not
* over the helper it delegates to. That distinction is the whole point:
* the structural guard in `backup-plan-classification.test.ts` reads the
* declared restore FILES, so a helper that exists and is never called
* passes it. Only a round trip through the route catches that.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { caches } from "@/lib/cache/server-cache";
import { encrypt } from "@/lib/crypto";
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
import { parseBackupPayload } from "@/lib/validations/backup";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserData: vi.fn(),
invalidateUserHealthScore: vi.fn(),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
const DAY = 24 * 60 * 60 * 1000;
interface AnalyticsEnvelope {
data: {
healthScore: {
composite: {
status: "ok" | "insufficient";
value?: { score: number; band: string; composition: string[] };
};
} | null;
} | null;
}
async function seedSession(username: string, role: "USER" | "ADMIN" = "USER") {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username,
email: `${username}@example.test`,
role,
heightCm: 178,
dateOfBirth: new Date("1985-07-09"),
// Pinned so the local day the row is filed under is a fact of the
// fixture rather than a property of the machine running the suite.
timezone: "UTC",
},
});
const session = await prisma.session.create({
data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
return user;
}
/** BP needs twelve paired readings inside the trailing 90-day window. */
async function seedBp(userId: string, now: number, days: number, sys: number) {
const prisma = getPrismaClient();
for (let i = 0; i < days; i++) {
const at = new Date(now - i * DAY);
await prisma.measurement.create({
data: {
userId,
type: "BLOOD_PRESSURE_SYS",
value: sys,
unit: "mmHg",
measuredAt: at,
},
});
await prisma.measurement.create({
data: {
userId,
type: "BLOOD_PRESSURE_DIA",
value: 78,
unit: "mmHg",
measuredAt: at,
},
});
}
}
/** SLEEP needs fourteen distinct wake-day nights. */
async function seedSleep(userId: string, now: number, nights: number) {
const prisma = getPrismaClient();
for (let i = 0; i < nights; i++) {
const wake = new Date(now - i * DAY);
wake.setUTCHours(6, 0, 0, 0);
await prisma.measurement.create({
data: {
userId,
type: "SLEEP_DURATION",
value: 450,
unit: "min",
measuredAt: wake,
sleepStage: "ASLEEP",
source: "APPLE_HEALTH",
},
});
}
}
/** ADIPOSITY needs one waist reading against the seeded height. */
async function seedWaist(userId: string, now: number) {
await getPrismaClient().measurement.create({
data: {
userId,
type: "WAIST_CIRCUMFERENCE",
value: 82,
unit: "cm",
measuredAt: new Date(now),
},
});
}
async function readAnalytics(): Promise<AnalyticsEnvelope> {
const { GET } = await import("@/app/api/analytics/route");
const res = await (GET as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/analytics"),
);
expect(res.status).toBe(200);
return (await res.json()) as AnalyticsEnvelope;
}
describe("the score a surface shows is the score that gets written down", () => {
it("persists the composite, band and composition the analytics route returned", async () => {
const user = await seedSession("hsr-roundtrip");
const now = Date.now();
await seedBp(user.id, now, 20, 122);
await seedSleep(user.id, now, 14);
await seedWaist(user.id, now);
const shown = (await readAnalytics()).data!.healthScore!;
expect(shown.composite.status).toBe("ok");
const rows = await getPrismaClient().healthScoreRecord.findMany({
where: { userId: user.id },
});
expect(rows).toHaveLength(1);
const row = rows[0];
// The shown value and the stored value are not compared through a fixture
// in the middle — the number asserted here came back over the wire from
// the same request that wrote the row.
expect(row.composite).toBe(shown.composite.value!.score);
expect(row.band).toBe(shown.composite.value!.band);
expect(row.composition).toEqual(shown.composite.value!.composition);
expect(row.pillarScores).toBeTruthy();
// JSONB does not preserve insertion order, so the SET of keys is the
// claim, not their order. The order that matters lives in `composition`,
// which is a text array and asserted above.
expect(
Object.keys(row.pillarScores as Record<string, number>).sort(),
).toEqual([...shown.composite.value!.composition].sort());
expect(row.inputFingerprint).toMatch(/^[0-9a-f]{64}$/);
expect(row.timezone).toBe("UTC");
expect(row.dayKey).toBe(new Date(now).toISOString().slice(0, 10));
// Nothing supplies these yet, and an invented default would be worse than
// an honest null the day the delta guard starts reading them.
expect(row.configVersion).toBeNull();
expect(row.configChangedAt).toBeNull();
});
it("leaves the recorded day untouched when the same day is computed again under a changed standing", async () => {
const user = await seedSession("hsr-never-restate");
const now = Date.now();
await seedBp(user.id, now, 20, 122);
await seedSleep(user.id, now, 14);
await seedWaist(user.id, now);
const first = (await readAnalytics()).data!.healthScore!;
expect(first.composite.status).toBe("ok");
const before = await getPrismaClient().healthScoreRecord.findFirstOrThrow({
where: { userId: user.id },
});
// Change what the score is made of, two ways at once: a fourth pillar
// joins the composition, and blood pressure grades worse. Either alone
// moves the live number.
await getPrismaClient().measurement.deleteMany({
where: { userId: user.id, type: "BLOOD_PRESSURE_SYS" },
});
await getPrismaClient().measurement.deleteMany({
where: { userId: user.id, type: "BLOOD_PRESSURE_DIA" },
});
await seedBp(user.id, now, 20, 168);
for (let i = 0; i < 30; i++) {
await getPrismaClient().measurement.create({
data: {
userId: user.id,
type: "ACTIVITY_STEPS",
value: 11_000,
unit: "steps",
measuredAt: new Date(now - i * DAY),
source: "APPLE_HEALTH",
},
});
}
// `/api/analytics` reads through a 60-second cache, so without this the
// second request would replay the first request's body and nothing would
// recompute. The assertion below is what caught it.
caches.analytics.deleteByPrefix(`${user.id}|`);
caches.insightsDerived.deleteByPrefix(`${user.id}|`);
const second = (await readAnalytics()).data!.healthScore!;
expect(second.composite.status).toBe("ok");
// The live number really did move — without this the test below would
// pass against a recomputation that changed nothing.
expect(second.composite.value!.score).not.toBe(
first.composite.value!.score,
);
const after = await getPrismaClient().healthScoreRecord.findMany({
where: { userId: user.id },
});
expect(after).toHaveLength(1);
expect(after[0]).toEqual(before);
});
});
describe("the recorded day survives a backup and a restore", () => {
it("carries the row out through the shared backup builder", async () => {
const user = await seedSession("hsr-backup");
const now = Date.now();
await seedBp(user.id, now, 20, 122);
await seedSleep(user.id, now, 14);
await seedWaist(user.id, now);
await readAnalytics();
const stored = await getPrismaClient().healthScoreRecord.findFirstOrThrow({
where: { userId: user.id },
});
const { payload, counts } = await buildFullBackupPayload(
getPrismaClient(),
user.id,
{ purpose: "disaster-recovery" },
);
expect(counts.healthScoreRecords).toBe(1);
const carried = (payload as { healthScoreRecords: unknown[] })
.healthScoreRecords;
expect(carried).toHaveLength(1);
expect(carried[0]).toMatchObject({
id: stored.id,
dayKey: stored.dayKey,
timezone: stored.timezone,
composite: stored.composite,
band: stored.band,
scoreVersion: stored.scoreVersion,
composition: stored.composition,
inputFingerprint: stored.inputFingerprint,
});
// And the file it produced is one the restore side will accept, rather
// than one that parses everywhere except at the moment it is needed.
expect(
parseBackupPayload(JSON.stringify(payload)).healthScoreRecords,
).toHaveLength(1);
});
it("brings the row back through the admin restore route", async () => {
const prisma = getPrismaClient();
const admin = await seedSession("hsr-restore", "ADMIN");
const now = Date.now();
await seedBp(admin.id, now, 20, 122);
await seedSleep(admin.id, now, 14);
await seedWaist(admin.id, now);
await readAnalytics();
const before = await prisma.healthScoreRecord.findFirstOrThrow({
where: { userId: admin.id },
});
const { payload } = await buildFullBackupPayload(prisma, admin.id, {
purpose: "disaster-recovery",
});
const backup = await prisma.dataBackup.create({
data: {
userId: admin.id,
type: "HEALTH_SCORE_RECORD_RESTORE_TEST",
data: encrypt(JSON.stringify(payload)),
},
});
// Wipe the table so a restore that carries nothing cannot pass by leaving
// the row that was already there.
await prisma.healthScoreRecord.deleteMany({ where: { userId: admin.id } });
expect(
await prisma.healthScoreRecord.count({ where: { userId: admin.id } }),
).toBe(0);
const { POST } = await import("@/app/api/admin/backups/[id]/restore/route");
const res = await POST(
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "RESTORE" }),
}) as unknown as Parameters<typeof POST>[0],
{ params: Promise.resolve({ id: backup.id }) },
);
const body = (await res.json()) as {
data: { summary: { healthScoreRecords: number } } | null;
error: string | null;
};
expect(body.error).toBeNull();
expect(res.status).toBe(200);
expect(body.data!.summary.healthScoreRecords).toBe(1);
const restored = await prisma.healthScoreRecord.findMany({
where: { userId: admin.id },
});
expect(restored).toHaveLength(1);
expect(restored[0].id).toBe(before.id);
expect(restored[0].dayKey).toBe(before.dayKey);
expect(restored[0].composite).toBe(before.composite);
expect(restored[0].band).toBe(before.band);
expect(restored[0].composition).toEqual(before.composition);
expect(restored[0].pillarScores).toEqual(before.pillarScores);
expect(restored[0].inputFingerprint).toBe(before.inputFingerprint);
expect(restored[0].computedAt.toISOString()).toBe(
before.computedAt.toISOString(),
);
});
});
describe("delete all data", () => {
it("removes the recorded days along with the rest of the record", async () => {
const user = await seedSession("hsr-wipe");
const now = Date.now();
await seedBp(user.id, now, 20, 122);
await seedSleep(user.id, now, 14);
await seedWaist(user.id, now);
await readAnalytics();
expect(
await getPrismaClient().healthScoreRecord.count({
where: { userId: user.id },
}),
).toBe(1);
const { DELETE } = await import("@/app/api/settings/data/route");
const res = await (
DELETE as unknown as (req: Request) => Promise<Response>
)(
new Request("http://localhost/api/settings/data", {
method: "DELETE",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "DELETE" }),
}),
);
expect(res.status).toBe(200);
expect(
await getPrismaClient().healthScoreRecord.count({
where: { userId: user.id },
}),
).toBe(0);
});
});