diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b54bdac41..1139d4d90 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -919,6 +919,16 @@ enum EnvironmentLocationSource { HOME TRAVEL DEVICE + + /// Migration 0217 created the physical type as `environment_location_source`, + /// like every other enum in this schema. This declaration carried no `@@map`, + /// so the client compiled every write against `public."EnvironmentLocationSource"` + /// — a type no database has ever had — and each insert into `environment_contexts` + /// died on `42704` before a row could commit. The map names the type that + /// exists; no migration follows, because the database was never the side that + /// drifted. `prisma-enum-mapping-guard.test.ts` now holds the whole schema to + /// this rule. + @@map("environment_location_source") } /// A per-day environmental observation (daily weather / daylight) for the diff --git a/src/__tests__/helpers/prisma-schema-names.ts b/src/__tests__/helpers/prisma-schema-names.ts new file mode 100644 index 000000000..310ecaaf3 --- /dev/null +++ b/src/__tests__/helpers/prisma-schema-names.ts @@ -0,0 +1,121 @@ +/** + * A minimal reader for the physical names `prisma/schema.prisma` declares. + * + * Prisma emits three kinds of identifier straight into the SQL it sends: the + * enum type name, the table name, and the column name. Each one is the `@@map` + * / `@map` value when present and the declared name verbatim when absent. If + * any of the three names something the database does not have, the statement + * dies at the driver with `42704` / `42P01` / `42703` — and nothing earlier in + * the pipeline can see it, because generation, typecheck, and every mocked + * unit test read the logical name only. + * + * Consumers: `prisma-enum-mapping-guard.test.ts` (the convention, no database) + * and `tests/integration/prisma-schema-name-drift.test.ts` (the physical + * comparison against a migrated Postgres). + */ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const SCHEMA_PATH = resolve( + dirname(fileURLToPath(import.meta.url)), + "../../../prisma/schema.prisma", +); + +export interface DeclaredEnum { + /** The enum name in the datamodel. */ + name: string; + /** The `@@map` value, or null when the declaration carries none. */ + mappedName: string | null; + /** The name Prisma puts in the SQL — `@@map` when present, else `name`. */ + physicalName: string; +} + +export interface DeclaredField { + name: string; + /** The `@map` value, or null when the field carries none. */ + mappedName: string | null; + /** The column name Prisma puts in the SQL. */ + physicalName: string; +} + +export interface DeclaredModel { + name: string; + mappedName: string | null; + /** The table name Prisma puts in the SQL. */ + physicalName: string; + /** Scalar + enum fields only — relation fields carry no column. */ + fields: DeclaredField[]; +} + +export interface DeclaredSchema { + enums: DeclaredEnum[]; + models: DeclaredModel[]; +} + +const BLOCK_MAP = /^\s*@@map\("([^"]+)"\)/m; +const FIELD_MAP = /@map\("([^"]+)"\)/; +/** `name Type[]? @attr…` — the two leading tokens are what matters. */ +const FIELD_LINE = /^\s*(\w+)\s+(\w+)(?:\[\])?\??\s*(.*)$/; + +/** Drop `//` and `///` lines so prose can never read as a declaration. */ +function stripComments(block: string): string { + return block + .split("\n") + .filter((line) => !/^\s*\/\//.test(line)) + .join("\n"); +} + +/** + * Read the datamodel. Deliberately regex-shaped rather than a real parser: the + * guard has to run inside `pnpm test` with no database and no Prisma engine, + * and the three declarations it reads (`enum`, `model`, field lines) are the + * most stable syntax in the file. Both consumers assert a floor on how much + * they found, so a parser that silently stops matching fails loudly instead of + * passing vacuously. + */ +export function readDeclaredSchema( + schemaPath: string = SCHEMA_PATH, +): DeclaredSchema { + const source = readFileSync(schemaPath, "utf8"); + const enums: DeclaredEnum[] = []; + const rawModels: Array<{ name: string; body: string }> = []; + + const block = /^(model|enum)\s+(\w+)\s*\{([\s\S]*?)^\}/gm; + for (let m = block.exec(source); m !== null; m = block.exec(source)) { + const [, kind, name, body] = m; + const clean = stripComments(body); + const mappedName = BLOCK_MAP.exec(clean)?.[1] ?? null; + if (kind === "enum") { + enums.push({ name, mappedName, physicalName: mappedName ?? name }); + } else { + rawModels.push({ name, body: clean }); + } + } + + const modelNames = new Set(rawModels.map((m) => m.name)); + + const models = rawModels.map(({ name, body }) => { + const mappedName = BLOCK_MAP.exec(body)?.[1] ?? null; + const fields: DeclaredField[] = []; + for (const line of body.split("\n")) { + if (/^\s*@@/.test(line)) continue; + const match = FIELD_LINE.exec(line); + if (!match) continue; + const [, fieldName, baseType, rest] = match; + // A field whose type is another model is a relation, not a column. The + // back-reference side carries no `@relation` attribute at all, so the + // type is what decides it — keying on `@relation` would miss half. + if (modelNames.has(baseType)) continue; + const fieldMapped = FIELD_MAP.exec(rest)?.[1] ?? null; + fields.push({ + name: fieldName, + mappedName: fieldMapped, + physicalName: fieldMapped ?? fieldName, + }); + } + return { name, mappedName, physicalName: mappedName ?? name, fields }; + }); + + return { enums, models }; +} diff --git a/src/__tests__/prisma-enum-mapping-guard.test.ts b/src/__tests__/prisma-enum-mapping-guard.test.ts new file mode 100644 index 000000000..94a866bac --- /dev/null +++ b/src/__tests__/prisma-enum-mapping-guard.test.ts @@ -0,0 +1,145 @@ +/** + * Structural guard — a Prisma enum cannot name a type the migrations never + * created. + * + * `EnvironmentLocationSource` was declared without an `@@map` while migration + * 0217 created the type as `environment_location_source`. Prisma writes the + * enum's physical name straight into the SQL, so every insert into + * `environment_contexts` asked Postgres for `public."EnvironmentLocationSource"` + * and got `42704`. The module read "0 days recorded" from the day it shipped. + * + * Nothing upstream could see it: the type is right in the generated client, the + * datamodel is internally consistent, and every mocked unit test only ever + * exercises the logical name. One enum out of forty-nine drifted, and it took a + * self-hoster reading Postgres error codes to find it. + * + * So the physical name is checked against the only other place that states it — + * the migration SQL. `CREATE TYPE`, `ALTER TYPE … RENAME TO`, and `DROP TYPE` + * are replayed in migration order, and every declared enum has to land on a + * type that survives to the end. The `@@map`-present rule is asserted + * separately, because an unmapped PascalCase enum is the specific mistake that + * happened and the message should say so rather than "type not found". + * + * Limits, stated plainly. This reads SQL text, not a database: it proves the + * migrations create the type, not that any particular deployment's database + * matches its migration ledger. It covers enum types only. The comprehensive + * check — every enum, table, and column name compared against a real migrated + * Postgres — is `tests/integration/prisma-schema-name-drift.test.ts`, which + * this guard exists to front-run at unit speed. + */ +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, it, expect } from "vitest"; + +import { readDeclaredSchema } from "./helpers/prisma-schema-names"; + +const MIGRATIONS_DIR = resolve( + dirname(fileURLToPath(import.meta.url)), + "../../prisma/migrations", +); + +const CREATE_TYPE = /CREATE\s+TYPE\s+(?:IF\s+NOT\s+EXISTS\s+)?"?([\w.]+)"?/gi; +const RENAME_TYPE = + /ALTER\s+TYPE\s+"?([\w.]+)"?\s+RENAME\s+TO\s+"?([\w.]+)"?/gi; +const DROP_TYPE = /DROP\s+TYPE\s+(?:IF\s+EXISTS\s+)?"?([\w.]+)"?/gi; + +/** `public.foo` and `foo` are the same type; the guard compares bare names. */ +function bare(name: string): string { + return name.includes(".") ? name.slice(name.lastIndexOf(".") + 1) : name; +} + +/** + * Statements that are commented out (the rollback recipes several migrations + * carry in a `--` block) must not count as executed SQL. + */ +function executableSql(sql: string): string { + return sql + .split("\n") + .filter((line) => !/^\s*--/.test(line)) + .join("\n"); +} + +/** Replay every migration in order and return the types left standing. */ +function liveEnumTypes(): Set { + const live = new Set(); + const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + + for (const dir of dirs) { + let sql: string; + try { + sql = readFileSync(join(MIGRATIONS_DIR, dir, "migration.sql"), "utf8"); + } catch { + continue; + } + const statements = executableSql(sql); + + // Order within one file matters: a migration that drops and recreates a + // type under a new name has to be replayed as written, so each statement + // kind is applied at its own offset rather than kind-by-kind. + const events: Array<{ at: number; apply: () => void }> = []; + for (const m of statements.matchAll(CREATE_TYPE)) { + const name = bare(m[1]); + events.push({ at: m.index, apply: () => void live.add(name) }); + } + for (const m of statements.matchAll(RENAME_TYPE)) { + const from = bare(m[1]); + const to = bare(m[2]); + events.push({ + at: m.index, + apply: () => { + live.delete(from); + live.add(to); + }, + }); + } + for (const m of statements.matchAll(DROP_TYPE)) { + const name = bare(m[1]); + events.push({ at: m.index, apply: () => void live.delete(name) }); + } + for (const event of events.sort((a, b) => a.at - b.at)) event.apply(); + } + return live; +} + +describe("prisma enums — the declared type is the migrated type", () => { + const { enums } = readDeclaredSchema(); + + it("reads the schema (a parser that stopped matching cannot pass vacuously)", () => { + expect(enums.length).toBeGreaterThan(40); + }); + + it("declares an @@map on every enum", () => { + const unmapped = enums + .filter((declared) => declared.mappedName === null) + .map((declared) => declared.name); + expect( + unmapped, + unmapped.length > 0 + ? `Enum(s) without @@map — Prisma will ask Postgres for the PascalCase ` + + `type name, which no migration creates: ${unmapped.join(", ")}` + : undefined, + ).toEqual([]); + }); + + it("finds the migrated types (a broken replay cannot pass vacuously)", () => { + expect(liveEnumTypes().size).toBeGreaterThan(40); + }); + + it("points every enum at a type the migrations leave standing", () => { + const live = liveEnumTypes(); + const missing = enums + .filter((declared) => !live.has(declared.physicalName)) + .map((declared) => `${declared.name} → "${declared.physicalName}"`); + expect( + missing, + missing.length > 0 + ? `Enum(s) naming a type no migration creates:\n ${missing.join("\n ")}` + : undefined, + ).toEqual([]); + }); +}); diff --git a/tests/integration/prisma-schema-name-drift.test.ts b/tests/integration/prisma-schema-name-drift.test.ts new file mode 100644 index 000000000..9865cb93a --- /dev/null +++ b/tests/integration/prisma-schema-name-drift.test.ts @@ -0,0 +1,222 @@ +/** + * Every name `prisma/schema.prisma` will put into SQL exists in the migrated + * database. + * + * `EnvironmentLocationSource` shipped with no `@@map` while migration 0217 had + * created the type as `environment_location_source`. Prisma writes the enum's + * physical name into the statement, so every insert into `environment_contexts` + * asked for `public."EnvironmentLocationSource"` and got `42704`. The nightly + * job failed on every run from the day the module shipped and the screen read + * "0 days recorded" the whole time. + * + * The reason it survived that long is the reason this file is an integration + * test and not a unit test: generation succeeds, typecheck succeeds, and a + * mocked Prisma client never emits a type name at all. Only a real migrated + * Postgres can answer whether the datamodel's physical names exist. + * + * So it compares all three name classes Prisma emits — enum type, table, + * column — against `pg_type` and `information_schema`. One direction only: + * every DECLARED name must exist physically. The reverse is deliberately not + * asserted, because a column dropped from the datamodel but left in the + * database breaks nothing, while a name the client sends and the database + * lacks breaks every statement that touches it. + * + * Also verified here: the environment write the drift actually blocked, once + * per enum value, against the live type. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { + readDeclaredSchema, + type DeclaredModel, +} from "@/__tests__/helpers/prisma-schema-names"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const prisma = getPrismaClient(); + +async function physicalEnumTypes(): Promise> { + const rows = await prisma.$queryRaw>` + SELECT t.typname + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE t.typtype = 'e' AND n.nspname = 'public' + `; + return new Set(rows.map((row) => row.typname)); +} + +async function physicalColumns(): Promise>> { + const rows = await prisma.$queryRaw< + Array<{ table_name: string; column_name: string }> + >` + SELECT table_name, column_name + FROM information_schema.columns + WHERE table_schema = 'public' + `; + const byTable = new Map>(); + for (const { table_name, column_name } of rows) { + const columns = byTable.get(table_name) ?? new Set(); + columns.add(column_name); + byTable.set(table_name, columns); + } + return byTable; +} + +function describeModel(model: DeclaredModel): string { + return `${model.name} → "${model.physicalName}"`; +} + +describe("prisma datamodel — declared names exist in the migrated database", () => { + const { enums, models } = readDeclaredSchema(); + + it("read the datamodel (a parser that stopped matching cannot pass vacuously)", () => { + expect(enums.length).toBeGreaterThan(40); + expect(models.length).toBeGreaterThan(80); + expect( + models.reduce((sum, model) => sum + model.fields.length, 0), + ).toBeGreaterThan(500); + }); + + it("has every declared enum type", async () => { + const physical = await physicalEnumTypes(); + const missing = enums + .filter((declared) => !physical.has(declared.physicalName)) + .map((declared) => `${declared.name} → "${declared.physicalName}"`); + expect( + missing, + missing.length > 0 + ? `Enum type(s) the database does not have:\n ${missing.join("\n ")}` + : undefined, + ).toEqual([]); + }); + + it("has every declared table", async () => { + const byTable = await physicalColumns(); + const missing = models + .filter((model) => !byTable.has(model.physicalName)) + .map(describeModel); + expect( + missing, + missing.length > 0 + ? `Table(s) the database does not have:\n ${missing.join("\n ")}` + : undefined, + ).toEqual([]); + }); + + it("has every declared column", async () => { + const byTable = await physicalColumns(); + const missing: string[] = []; + for (const model of models) { + const columns = byTable.get(model.physicalName); + if (!columns) continue; // reported by the table assertion above + for (const field of model.fields) { + if (!columns.has(field.physicalName)) { + missing.push( + `${model.name}.${field.name} → "${model.physicalName}"."${field.physicalName}"`, + ); + } + } + } + expect( + missing, + missing.length > 0 + ? `Column(s) the database does not have:\n ${missing.join("\n ")}` + : undefined, + ).toEqual([]); + }); +}); + +describe("environment context — the write the enum drift blocked", () => { + beforeEach(async () => { + await truncateAllTables(prisma); + }); + + it("stores a row under every EnvironmentLocationSource value", async () => { + const user = await prisma.user.create({ + data: { username: "env-enum", email: "env-enum@example.test" }, + select: { id: true }, + }); + + for (const [index, source] of ( + ["HOME", "TRAVEL", "DEVICE"] as const + ).entries()) { + const date = `2026-03-0${index + 1}`; + await prisma.environmentContext.upsert({ + where: { userId_date: { userId: user.id, date } }, + create: { + userId: user.id, + date, + lat: 52.5, + lon: 13.4, + locationLabel: "Somewhere", + source, + tempMean: 12.5, + fetchedAt: new Date(), + }, + update: { source, tempMean: 12.5 }, + }); + } + + const stored = await prisma.environmentContext.findMany({ + where: { userId: user.id }, + orderBy: { date: "asc" }, + select: { date: true, source: true }, + }); + expect(stored).toEqual([ + { date: "2026-03-01", source: "HOME" }, + { date: "2026-03-02", source: "TRAVEL" }, + { date: "2026-03-03", source: "DEVICE" }, + ]); + + // Idempotent — the nightly lookback re-runs the same days every night. + await prisma.environmentContext.upsert({ + where: { userId_date: { userId: user.id, date: "2026-03-01" } }, + create: { + userId: user.id, + date: "2026-03-01", + lat: 52.5, + lon: 13.4, + locationLabel: "Somewhere", + source: "HOME", + fetchedAt: new Date(), + }, + update: { source: "TRAVEL", tempMean: 14 }, + }); + const reread = await prisma.environmentContext.findMany({ + where: { userId: user.id }, + }); + expect(reread).toHaveLength(3); + expect(reread.find((row) => row.date === "2026-03-01")?.source).toBe( + "TRAVEL", + ); + }); + + it("filters on the enum column (the read side sends the type name too)", async () => { + const user = await prisma.user.create({ + data: { username: "env-enum-read", email: "env-enum-read@example.test" }, + select: { id: true }, + }); + await prisma.environmentContext.create({ + data: { + userId: user.id, + date: "2026-04-01", + lat: 52.5, + lon: 13.4, + locationLabel: "Somewhere", + source: "TRAVEL", + fetchedAt: new Date(), + }, + }); + + await expect( + prisma.environmentContext.count({ + where: { userId: user.id, source: { in: ["HOME", "DEVICE"] } }, + }), + ).resolves.toBe(0); + await expect( + prisma.environmentContext.count({ + where: { userId: user.id, source: "TRAVEL" }, + }), + ).resolves.toBe(1); + }); +});