From 5fd544706988eb12b96e046c9a45ef31d681dd08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 15:38:50 +0200 Subject: [PATCH] Make the band a database type instead of a text check The delete-all-data guard seeds every table straight from information_schema so a table added next year is asserted without anyone remembering. A CHECK constraint defeats that: the generic seeder has no way to guess a value the constraint accepts, and the whole suite failed on the new table. A Postgres enum carries the same closed set, the seeder reads it from pg_enum, and Prisma now types the column instead of trusting a string. Dropped the day-key format and non-empty-composition checks with it. The writer builds the key through the timezone helper and refuses an empty composition before inserting, and the restore validates both against the wire schema before the transaction opens, so a third copy in the database bought nothing and cost a hand-written line in a fixture whose point is not having one. The composite range check stays: free to check, and a number outside nought to a hundred renders as a score nobody can read. --- .../0290_health_score_record/migration.sql | 27 ++++++++++++------- prisma/schema.prisma | 20 ++++++++++---- .../analytics/score/__tests__/record.test.ts | 16 ++++++++--- src/lib/export/health-score-backup.ts | 2 +- 4 files changed, 45 insertions(+), 20 deletions(-) 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),