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:
Marc-André Bombeck committed 2026-07-26 14:37:57 +02:00
1 parent 054eb51e96
commit a8d798442f
4 files changed
+498

No files matched your search

@@ -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([]);
});
});