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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-31 15:38:50 +02:00
1 parent cd7c6f44c8
commit 5fd5447069
4 files changed
+45 -20

No files matched your search

@@ -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"
+15 -5
View File
@@ -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
@@ -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<string>();
for (const score of [95, 85, 65, 55, 40, 20, 0]) {
const built = report([
+1 -1
View File
@@ -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),