Post

Prisma's other @@map spelling, and the foreign keys we were dropping

Prisma accepts @@map("users") and @@map(name: "users"). Wyro misread the second, and dropped every foreign key into a renamed table. Both are fixed.

Try Wyro

Prisma lets you give a model's table a different name in two ways: @@map("accounts") and @@map(name: "accounts"). They mean exactly the same thing. Until today Wyro understood only the first. Given the second, it read a table called name: accounts, with the quotes stripped and the argument's label left in. Fixing that turned up a bigger problem behind it: a foreign key that points at a renamed table, in either spelling, never reached the code Wyro generates. Both are fixed, along with two more ways of losing a foreign key, in Prisma schemas split across files and in Drizzle.

Two spellings of the same name

@@map takes a single argument, name, and Prisma accepts it with or without the label. Field-level @map works the same way:

model Account {
  id                String @id @default(cuid())
  userId            String
  providerAccountId String @map(name: "provider_account_id")
  user              User   @relation(fields: [userId], references: [id])

  @@map(name: "accounts") // the same as @@map("accounts")
}

The labelled form is the less common one, but it isn't rare. GitHub's code search estimates about 2,350 .prisma files containing @@map(name:, and all 50 we sampled really do use it. shadcn's taxonomy starter writes all five of its tables that way and has been forked more than 2,700 times. We ran into it in Saas-Kit-prisma, a Next.js SaaS starter whose six models all use it:

Two boxes. Positional: @@map("accounts"), read as accounts before and now. Labelled: @@map(name: "accounts"), read as name: accounts before and accounts now. Below, a table of the six models in Saas-Kit-prisma. Account, Session, User, VerificationToken, Subscription and Todo were read before as name: accounts, name: sessions, name: users, name: verification_tokens, name: subscriptions and name: todos, and are read now as accounts, sessions, users, verification_tokens, subscriptions and todos.
The table names Wyro read from Saas-Kit-prisma's schema (commit 5009c8c), with the parser before and after the fix.

What the wrong name broke

The table name is how everything else finds a table, so a wrong one fails quietly in several places:

  • Findings named tables that don't exist, such as name: users.
  • Raw SQL couldn't be matched. Wyro matches the tables named in $queryRaw and $executeRaw SQL against the tables in the schema, so it can tell what a route touches even when the query is raw SQL rather than a model call. A DELETE FROM sessions found no table called sessions, and the write went unrecorded.
  • Generated code didn't compile. A schema imported to the canvas and compiled back to code came out as export const name: accounts = pgTable("name: accounts", …), which isn't valid TypeScript.

One thing it didn't break: the rule that lets a sign-in route reach your identity tables without being flagged. That rule splits names on punctuation, and name: users still contains users. On the starter kit, the same rules fired on the same routes before and after the fix. Only the table names in the findings changed.

The bigger bug: foreign keys that never arrived

When Wyro reads a schema, it records each foreign key as the name of the table it points to. The compiler then looks that table up by name, and drops a reference to a table that doesn't exist, so it can never generate a constraint that points nowhere. The Prisma reader was recording the model name instead. userId pointed at User, the table is called users, the compiler found nothing, and the foreign key was dropped. There was no error and no warning. The column just came out as plain text, without a constraint or an index.

Two code panels of a generated src/db/schema.ts. Before: export const name: accounts = pgTable("name: accounts", { userId: text("userId").notNull() }), tagged not valid TypeScript, no foreign key, and no index on userId. Now: export const accounts = pgTable("accounts", { userId: text("userId").notNull().references(() => users.id) }) with index("accounts_userId_idx").on(t.userId).
The accounts table from Saas-Kit-prisma, compiled to a Drizzle schema by the old and new code. Real compiler output, with unchanged columns elided.

This had nothing to do with how @@map was spelled. Any Prisma schema that renames its tables lost every foreign key pointing into them. In the starter kit that was all four:

Diagram of Saas-Kit-prisma's six tables. accounts.userId, sessions.userId and todos.user_id point at users.id, and users.subscription_id points at subscriptions.id. verification_tokens has no foreign key. Before the fix, 0 of the 4 foreign keys reached the generated schema; now all 4 do, each with a constraint and an index. In shadcn-ui/taxonomy, 0 of 3 before and 3 of 3 now.
Every foreign key in Saas-Kit-prisma, as read from its schema. Before the fix the compiler dropped all four; now each one arrives with a constraint and an index.

Two more cases lost foreign keys the same way:

  • Prisma schemas split across files. Prisma lets one schema span several .prisma files in a folder. Wyro read each file on its own, so author User in post.prisma didn't know that User was a model in user.prisma. The relation was reported as a field of unknown type, and its foreign key was lost. Names now resolve across every file of the schema, including a file that holds nothing but enums.
  • Drizzle, when a variable and its table have different names. .references(() => usersTable.id) names a variable. If that variable is export const usersTable = pgTable("app_users", …), the reference pointed at usersTable, and the compiler dropped it the same way. It now resolves to app_users, including when the two tables are declared in different files.

In a monorepo, names resolve within one schema only, meaning the files under one datasource, so two apps that each declare a User model never pick up each other's table.

How the bug survived a test

The Prisma reader had a test for exactly this foreign key, and it passed. It asserted that the reference pointed at User, because that is what the parser produced when the test was written. The test described what the parser did, not what the compiler needed, so it pinned the bug in place. It now asserts users, and a new test follows the foreign key all the way through the compiler instead of stopping at the parser.

  • Fixed@@map(name: "x") and @map(name: "x") are read as x, the same as @@map("x") and @map("x").
  • FixedA Prisma foreign key points at the table a model maps to, not the model name, so it survives compilation.
  • FixedIn a Prisma schema split across files, relations and enums resolve across the whole schema, and two schemas in one repository stay separate.
  • FixedA Drizzle foreign key resolves through the variable to the table it declares.

Every fix has a regression test that fails on the old code, eight new and the one corrected above, and the full suite of 1,884 tests passes. Both starter kits above were re-run end to end. Saas-Kit-prisma's six tables and taxonomy's five are now read by their real names, and all seven of their foreign keys reach the generated schema.

Still open

Writing this turned up three Prisma defaults that the compiler still gets wrong. We are listing them rather than leaving them out of the screenshots:

  • @default(cuid()) comes out as the literal string "cuid()". Every insert that doesn't set an id gets that same value, so the second one fails on the primary key.
  • @default(autoincrement()) comes out as default(0).
  • @default(now(), map: "DF_created"), the SQL Server way of naming a default, keeps the map: text in the default value.

All three affect generated code only, not the architecture check.

Check your own

For a public repository, paste it into the scan page. There's no account to create. For a private one, the same check runs entirely on your machine with Node 18 or later:

curl -fsSL https://wyro.in/wyro-check.js -o wyro-check.js
node wyro-check.js path/to/your/backend

If you imported a Prisma or Drizzle project into Wyro before today, import it again to pick up its foreign keys. The GitHub Action checks for a new checker on every run, so it picks up these fixes on its next one, unless you have pinned its checksum.

  • changelog
  • parser
  • prisma
  • drizzle
  • compiler