diff --git a/prisma/migrations/0290_health_score_record/migration.sql b/prisma/migrations/0290_health_score_record/migration.sql index 6bcd13b9b..6796babd6 100644 --- a/prisma/migrations/0290_health_score_record/migration.sql +++ b/prisma/migrations/0290_health_score_record/migration.sql @@ -10,20 +10,33 @@ -- * 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, so a row can never carry a band no surface knows --- how to render. +-- * 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" VARCHAR(8) NOT NULL, + "band" "health_score_band" NOT NULL, "score_version" INTEGER NOT NULL, "composition" TEXT[] NOT NULL, "pillar_scores" JSONB NOT NULL, @@ -34,14 +47,8 @@ CREATE TABLE "health_score_records" ( "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "health_score_records_pkey" PRIMARY KEY ("id"), - CONSTRAINT "health_score_records_band_check" - CHECK ("band" IN ('green', 'yellow', 'red')), CONSTRAINT "health_score_records_composite_range_check" - CHECK ("composite" >= 0 AND "composite" <= 100), - CONSTRAINT "health_score_records_day_key_check" - CHECK ("day_key" ~ '^\d{4}-\d{2}-\d{2}$'), - CONSTRAINT "health_score_records_composition_not_empty_check" - CHECK (array_length("composition", 1) >= 1) + CHECK ("composite" >= 0 AND "composite" <= 100) ); CREATE UNIQUE INDEX "health_score_records_user_id_day_key_key" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8d7af385f..8ea9df74b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5056,6 +5056,17 @@ model UserAchievement { // ─── 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 @@ -5084,11 +5095,10 @@ model HealthScoreRecord { /// The composite 0-100 as shown. Integer, matching the composite's own /// rounding. composite Int - /// The band as shown (`green` / `yellow` / `red`). 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 String @db.VarChar(8) + /// 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 diff --git a/src/lib/analytics/score/__tests__/record.test.ts b/src/lib/analytics/score/__tests__/record.test.ts index 96842301d..3f95d979e 100644 --- a/src/lib/analytics/score/__tests__/record.test.ts +++ b/src/lib/analytics/score/__tests__/record.test.ts @@ -9,6 +9,7 @@ */ 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"; @@ -114,11 +115,18 @@ const HEALTHY = [ ]; 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", () => { - // The migration's CHECK constraint spells out the same three. A band the - // composite can reach and the column refuses is a runtime constraint - // violation on a write path that swallows its own failures, so the two - // lists have to be the same list. const produced = new Set(); for (const score of [95, 85, 65, 55, 40, 20, 0]) { const built = report([ diff --git a/src/lib/export/health-score-backup.ts b/src/lib/export/health-score-backup.ts index c8b7f9985..a0a70a0b8 100644 --- a/src/lib/export/health-score-backup.ts +++ b/src/lib/export/health-score-backup.ts @@ -111,7 +111,7 @@ export async function buildHealthScoreBackupSection( dayKey: row.dayKey, timezone: row.timezone, composite: row.composite, - band: row.band as ScoreBand, + band: row.band, scoreVersion: row.scoreVersion, composition: row.composition, pillarScores: readPillarScores(row.pillarScores),