mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(environment): name the enum type the database actually has
Migration 0217 created the type as `environment_location_source`; the datamodel declared `EnvironmentLocationSource` with no `@@map`, so the client compiled every write against `public."EnvironmentLocationSource"`. Postgres answered 42704, the upsert rolled the transaction back, and the module sat at "0 days recorded" from the day it shipped — one enum out of forty-nine, and the only one without a map. No migration follows. `prisma migrate diff` against a database built from the committed migrations reports the enum rename and the `environment_contexts.source` column rebuild before the map and nothing after it: the database was never the side that drifted, and renaming a live type to match a datamodel typo would be the wrong direction. Two guards, because the two questions are different. The unit guard replays CREATE TYPE / ALTER TYPE RENAME / DROP TYPE across the migration set and holds every enum to a type that survives it. The integration guard compares all three name classes Prisma emits — enum type, table, column — against a real migrated Postgres, which is the only place a name mismatch is observable at all: it survives generation, typecheck, and every mocked test untouched. Refs #646
This commit is contained in:
1 parent
054eb51e96
commit
a8d798442f
4 files changed
+498
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<string> {
|
||||
const live = new Set<string>();
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<Set<string>> {
|
||||
const rows = await prisma.$queryRaw<Array<{ typname: string }>>`
|
||||
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<Map<string, Set<string>>> {
|
||||
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<string, Set<string>>();
|
||||
for (const { table_name, column_name } of rows) {
|
||||
const columns = byTable.get(table_name) ?? new Set<string>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user