Prisma vs Drizzle comparison

Cursor

6 条消息

Prisma 对比 Drizzle 有哪些优缺点,上代码举例一下

AI 助手

先看一下项目里现在用的是 Prisma 还是 Drizzle,再对照代码举例说明两者的优缺点。

保罗小店已经拍板用 Drizzle。两者不是「谁全面碾压谁」,而是:Prisma 更像带客户端的 ORM,Drizzle 更像带类型的 SQL 层。


一句话定位

PrismaDrizzle
本质Schema DSL + 生成式 ClientTypeScript schema + SQL 风格查询
你写的是「给我带 SKU 的商品」FROM products LEFT JOIN product_skus
适合CRUD 快、团队 SQL 弱、要 Studio要控 SQL、行锁、部分唯一索引、包体积
运行时历史上有 Query Engine;现在可走 driver adapter,仍偏厚几乎就是 SQL 生成器,无独立引擎

1. Schema:DSL vs TypeScript

Prisma 用独立语言,列名映射靠 @map

enum UserRole {
  USER
  ADMIN
}

model User {
  id           String   @id @default(uuid()) @db.Uuid
  email        String   @unique @db.VarChar(254)
  passwordHash String   @map("password_hash") @db.VarChar(255)
  role         UserRole @default(USER)
  createdAt    DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
  updatedAt    DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
  orders       Order[]

  @@map("users")
  @@index([role, status])
}

改完必须 prisma generate,类型才更新。

Drizzle 就是 TS,列名写在第二个参数里,类型立刻跟着变:

import { pgTable, uuid, varchar, timestamp, pgEnum, index } from "drizzle-orm/pg-core";

export const userRole = pgEnum("user_role", ["USER", "ADMIN"]);

export const users = pgTable(
  "users",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    email: varchar("email", { length: 254 }).notNull().unique(),
    passwordHash: varchar("password_hash", { length: 255 }).notNull(),
    createdAt: timestamp("created_at", { withTimezone: true, precision: 6 })
      .notNull()
      .defaultNow(),
  },
  (t) => [index("users_role_status_idx").on(t.role, t.status)],
);

这正是后端文档里选 Drizzle 的原因:password_hash 显式写出,不靠 @map

Prisma 赢:Studio、校验、关系图、文档都围绕一份 .prisma
Drizzle 赢:schema 可拆文件、可复用、可跟 Zod 共用同一套字段定义。


2. CRUD:Client API vs SQL 形状

查已上架商品 + 最低价 SKU。

Prisma — 嵌套 include,写起来短:

const products = await prisma.product.findMany({
  where: { status: "PUBLISHED" },
  include: {
    skus: {
      where: { isListed: true },
      orderBy: { price: "asc" },
    },
    coverFile: true,
  },
  orderBy: { publishedAt: "desc" },
});

生成的 SQL 往往是多条查询或较重的 JOIN;你不太容易一眼看出最终语句。

Drizzle — 查询长得像 SQL:

const rows = await db
  .select({
    id: products.id,
    slug: products.slug,
    title: products.title,
    skuName: productSkus.name,
    price: productSkus.price,
  })
  .from(products)
  .leftJoin(productSkus, eq(productSkus.productId, products.id))
  .where(and(eq(products.status, "PUBLISHED"), eq(productSkus.isListed, true)))
  .orderBy(desc(products.publishedAt), asc(productSkus.price));

db.query.products.findMany({ with: { skus: true } }) 也能走关系 API,但复杂过滤、投影、锁,最终还是 select().from() 更稳。

Prisma 赢:嵌套读写、关系图查询、CRUD 样板少。
Drizzle 赢:SELECT 列、JOIN、WHERE 完全可控,没有 N+1 黑盒。


3. 嵌套写入

创建商品 + 一个 SKU。

Prisma 一条 createskus.create

await prisma.product.create({
  data: {
    slug: "paul-kit",
    title: "保罗工具箱",
    status: "DRAFT",
    skus: {
      create: { name: "标准版", price: 5800, isListed: true, sortOrder: 0 },
    },
  },
});

Drizzle 没有等价的「嵌套 create」,事务里自己插:

await db.transaction(async (tx) => {
  const [product] = await tx
    .insert(products)
    .values({ slug: "paul-kit", title: "保罗工具箱", status: "DRAFT" })
    .returning({ id: products.id });

  await tx.insert(productSkus).values({
    productId: product.id,
    name: "标准版",
    price: 5800,
    isListed: true,
    sortOrder: 0,
  });
});

样板更多,但插入顺序、返回列、失败回滚都看得见。


4. 事务与行锁(本店真正会痛的地方)

优惠券核销:SELECT … FOR UPDATE,再判断 HELD + COMMITTED < max_redemptions。这是文档里的硬约束。

Prisma 交互式事务有,行锁要自己拼 SQL:

await prisma.$transaction(async (tx) => {
  const rows = await tx.$queryRaw<Coupon[]>`
    SELECT * FROM coupons WHERE id = ${couponId}::uuid FOR UPDATE
  `;
  const coupon = rows[0];
  if (!coupon) throw new Error("COUPON_NOT_FOUND");

  const held = await tx.couponRedemption.count({
    where: { couponId, status: { in: ["HELD", "COMMITTED"] } },
  });
  if (held >= coupon.maxRedemptions) throw new Error("COUPON_EXHAUSTED");

  await tx.couponRedemption.create({
    data: { orderId, couponId, userId, discount, status: "HELD" },
  });
});

Client API 和 $queryRaw 混用,锁语义容易漏。

Drizzlefor("update") 就是 SQL:

await db.transaction(async (tx) => {
  const [coupon] = await tx
    .select()
    .from(coupons)
    .where(eq(coupons.id, couponId))
    .for("update");

  if (!coupon) throw new Error("COUPON_NOT_FOUND");

  const [{ used }] = await tx
    .select({ used: sql<number>`count(*)::int` })
    .from(couponRedemptions)
    .where(
      and(
        eq(couponRedemptions.couponId, couponId),
        inArray(couponRedemptions.status, ["HELD", "COMMITTED"]),
      ),
    );

  if (used >= coupon.maxRedemptions) throw new Error("COUPON_EXHAUSTED");

  await tx.insert(couponRedemptions).values({
    orderId,
    couponId,
    userId,
    discount,
    status: "HELD",
  });
});

支付回调、SKU 锁价、订单金额快照,都属于「必须看见 SQL」的路径。这里 Drizzle 明显更合适。


5. 部分唯一索引、CHECK

「每个商品最多一个 is_current = true 版本」—— Postgres 部分唯一索引。

Prisma 长期表达不好,常要手写 SQL 进 migration:

CREATE UNIQUE INDEX product_versions_one_current
  ON product_versions (product_id)
  WHERE is_current = true;

schema 里看不到这条约束,下次 migrate 还可能对不齐。

Drizzle 写在 schema 里,生成迁移时带着走:

import { uniqueIndex } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";

export const productVersions = pgTable(
  "product_versions",
  { /* ... */ isCurrent: boolean("is_current").notNull() },
  (t) => [
    uniqueIndex("product_versions_one_current")
      .on(t.productId)
      .where(sql`${t.isCurrent} = true`),
  ],
);

金额 CHECK(payable = subtotal - discount)同理:Drizzle 用 check(),Prisma 多半落在 raw SQL。


6. 类型从哪来

Prisma

import type { Product, Prisma } from "@prisma/client";

type CreateProduct = Prisma.ProductCreateInput;

类型完整、嵌套 payload 自动生成,但必须跑 generate;CI 漏了就会和 schema 脱节。

Drizzle

import type { InferSelectModel, InferInsertModel } from "drizzle-orm";

type Product = InferSelectModel<typeof products>;
type NewProduct = InferInsertModel<typeof products>;

改 schema 立刻反映到类型,没有 generate 步骤。复杂 JOIN 的返回类型要靠 select({ ... }) 推出来,写漏列就会变成宽 any 感——这是 Drizzle 最常见的 DX 坑。


7. 迁移与运行时

PrismaDrizzle
生成迁移prisma migrate devdrizzle-kit generate
应用迁移prisma migrate deploydrizzle-kit migrate
开发偷懒db push(两边都有,生产都禁止)同左
包体 / 部署曾必须带 engine 二进制;adapter 模式好转,仍重几乎无额外二进制
调试 SQLlog: ["query"],语句常经过引擎改写logger: true,基本就是你写的那句
Studio成熟drizzle-studio,弱一档
生态文档、StackOverflow、Nest 示例更多2024 后增长很快,边角案例仍少

8. 优缺点对照(按实际踩坑排)

Prisma 优点

  • 嵌套 CRUD、include / select 对业务表关系很省事
  • Client 类型、Studio、校验一体,上手快
  • 中间件 / 扩展(软删、审计)现成套路多
  • 团队 SQL 不熟时,能把正确性托付给 Client

Prisma 缺点

  • SQL 不透明:N+1、多余 JOIN、难写 FOR UPDATE / CTE
  • schema 是另一门语言;snake_case@map,和库表对账累
  • 部分索引、CHECK、复杂约束经常逃到 raw SQL
  • 运行时更重;generate 漏跑会类型过期
  • 复杂报表最后还是 $queryRaw,两套风格并存

Drizzle 优点

  • Schema = TypeScript,列名、枚举、索引都在代码里
  • 查询接近 SQL,行锁、事务、投影好控
  • 轻:适合 Nest + postgres.js 这种「自己管连接」的栈
  • 迁移和 schema 同源,部分唯一索引能进 TS
  • 和 Zod 组合自然(createInsertSchema 一类)

Drizzle 缺点

  • 关联查询、嵌套写入要手写 JOIN / 事务,样板多
  • 关系 API(db.query)一复杂就不够用,团队必须会 SQL
  • 推类型有时难看,JOIN 漏选列要自己兜
  • Studio、文档、Nest 官方示例不如 Prisma
  • timestamp 漏写 withTimezone: true 会变成无时区列——后端文档已经点名这个坑

9. 对本项目意味着什么

保罗小店这几条,Prisma 会别扭、Drizzle 刚好:

  1. 金额整数 + CHECKpayable = subtotal - discount
  2. 优惠券 / SKU 行锁FOR UPDATE
  3. 部分唯一索引(当前版本、用户对同一券的占用)
  4. snake_case 列 + camelCase TS 字段,不要满屏 @map
  5. 订单快照:插入时显式列出 unit_pricesnapshot,不允许 Client 隐式带关系改历史价

Prisma 更适合「后台 CMS、关系全是 include」的阶段。这个店的一致性边界在支付和库存式核销,SQL 必须可见,所以文档里用 Drizzle 换掉 Prisma 是合理选择,不是跟风。

如果以后加一块纯管理端列表(用户、审计、设置),可以在 Drizzle 里用 db.query.*.findMany({ with }) 减轻样板,不必为此切回 Prisma。

  1. include: { skus } 本质上就是 join 了 sku 表是吧,这个属于是 prisma 自己的映射规则,sku 表叫什么名字是 schema 文件 Model xxx 决定,而 drizzle 是需要变量实体引用
  2. 嵌套写入是指一次执行两个 sql 插入指令吗,反正我一眼看到 prisma 的写法,其实你根本看不出来执行了几条 sql,但 drizzle 一眼能看出来
  3. 事务与行锁我需要你能给出更好理解的说明,不仅仅只是描述这两个 orm 的使用差异
  4. 迁移和运行时:我只记得 prisma 会在数据库里插入一个 _prisma_migrations,drizzle 是怎么知道需要迁移到那个版本的呢
AI 助手

把四个点对准:include 是不是 JOIN、嵌套写入实际跑几条 SQL、事务/行锁在业务里干什么,以及 Drizzle 怎么记迁移版本。我会对照 Prisma / Drizzle 现在的迁移机制核对一下。

你的 1、2 方向对,但有两处需要拧紧:include 不等于你写了一句 JOIN嵌套写入也不只是「两条 INSERT」。3 和 4 按机制讲。


1. include: { skus } 不是「你写了 JOIN」

它是 Prisma 的关系加载约定:拿到商品时,把关联的 SKU 一并填进 JS 对象。底层怎么取数,是 Prisma 自己决定的,API 不保证是一条 JOIN

历史上默认是两次查询,应用层拼

SELECT * FROM products WHERE status = 'PUBLISHED';
SELECT * FROM product_skus WHERE product_id IN (...);

较新版本可以改成库内 LATERAL JOIN + json_agg,一次返回嵌套 JSON。所以:

  • 语义:加载关系
  • 实现:可能是 JOIN,也可能是 IN (...) 再内存合并
  • 你在代码里看不出来走哪条

名字分三层,别混:

PrismaDrizzle
JS 字段include: { skus } 里的 skusproductSkus 这个变量
Model / schema 对象model ProductSku { ... }export const productSkus = pgTable(...)
真实表名@@map("product_skus"),没写就按规则从 Model 名推pgTable("product_skus", ...) 第一个参数

Prisma 里 skus 不是表名,是 Model 上声明的关系字段:

model Product {
  id   String       @id
  skus ProductSku[]   // ← include 用这个名字
  @@map("products")
}

model ProductSku {
  id        String  @id
  productId String  @map("product_id")
  product   Product @relation(fields: [productId], references: [id])
  @@map("product_skus")   // ← 真正的表名
}

Client 是 prisma.product.findMany({ include: { skus: true } })。表叫什么,由 @@map 决定;关系叫什么,由字段 skus 决定。这就是 Prisma 的映射规则。

Drizzle 没有「按名字找关系」这一层。JOIN 必须引用你 import 进来的表对象

import { products, productSkus } from "./schema";

db.select().from(products).leftJoin(productSkus, eq(productSkus.productId, products.id));

db.query.products.findMany({ with: { skus: true } }) 看起来像 include,但 skus 仍然是你在 relations() 里注册过的名字,底层还是那张 productSkus 表对象。少一层「Model 名 → Client → 表名」的生成映射。


2. 嵌套写入:一次 API,多条 SQL,外面包事务

对。createskus.create 不是一条能插入两张表的 SQL。Postgres 一次 INSERT 只能进一张表。Prisma 会在一个事务里拆开执行,大致是:

BEGIN;
INSERT INTO products (...) VALUES (...) RETURNING *;
INSERT INTO product_skus (product_id, ...) VALUES (...);
-- 常常还有额外 SELECT,用来拼你要 return 的嵌套对象
COMMIT;

失败则 ROLLBACK,不会留下「有商品没 SKU」。

你的判断对:从 Prisma 那一行看不出几条 SQL、有没有多打 SELECT、JOIN 还是二次查询。 要开 query log 才知道。Drizzle 每个 insert() 对应你看见的那条语句,条数就是代码行数(外加你自己写的 transaction)。

补充两点:

  • 嵌套写入还可以是 updateskus: { create / update / deleteMany },SQL 数量随嵌套变,更难目测。
  • Prisma 为了返回完整对象,经常在 INSERT 后再 SELECT。Drizzle 的 .returning({ id: products.id }) 写了才查,没写就没有。

3. 事务和行锁:先不管 ORM

这两件事解决的是:两个人同时改同一份数据时,数据库保证什么。 ORM 只是把 SQL 发出去。

事务:一组语句要么全成,要么全不成

下单在本店是一个一致性边界,文档里写了四步要在同一事务里:

  1. 读并锁住 SKU 现价
  2. 可选:锁优惠券,插入 HELD 核销
  3. orders + order_items
  4. payments

假设券插进去了,订单 INSERT 因 check 失败。没有事务:库里会留下一张废核销,额度被占,用户却没订单。有事务:第四步失败,1–3 全部撤回,像没发生过。

事务保证的是这一组语句的原子性,不保证「两个事务不会同时读到同一行」。后一个要靠锁。

为什么会超卖:时间窗口

max_redemptions = 1。用户 A、B 同时用这张券,各开一个事务,默认 Postgres 是 READ COMMITTED

时刻    A                              B
t1      读到已用 0,限额 1
t2                                     读到已用 0,限额 1   ← B 也觉得还能用
t3      INSERT HELD,COMMIT
t4                                     INSERT HELD,COMMIT  ← 超卖

两人读的都是「当时已提交的数据」。A 提交后,B 的事务若不再读一次,仍拿着自己的旧结论往下写。这不是 ORM 的 bug,是读和写之间没有互斥

唯一约束能挡一部分(例如「同一用户同一张券不能占两笔」),挡不住「全局限额 1 张、两个不同用户同时抢」。那是计数问题,需要锁住这张券这一行

行锁:FOR UPDATE 在锁哪一行

SELECT * FROM coupons WHERE id = $1 FOR UPDATE;

含义:

  • 锁的是这一行,不是整张 coupons 表。别的券照样卖。
  • 第一个事务拿到锁;第二个事务执行到同一句时堵住,等到第一个 COMMITROLLBACK
  • 第一个提交后,第二个再读,看到的是新数据(已用 1),于是拒绝。

接上时间线:

时刻    A                              B
t1      SELECT ... FOR UPDATE          (B 也执行同一句,进入等待)
t2      已用 0 < 1,INSERT HELD
t3      COMMIT,释放锁
t4                                     被唤醒,再读:已用 1,拒绝

没有 FOR UPDATESELECT 只是快照,读完锁就没了,窗口还在。

FOR UPDATE写锁:别人不能再 FOR UPDATEUPDATE 这行,普通 SELECT 默认仍能读(不会看到你未提交的改动)。所以列表页看券不会被下单堵住;真正抢额度的下单路径会串行化在这一行上。

SKU 锁价同理:不锁的话,A 读到 5800,管理员同时改成 6800,A 仍按 5800 下单。锁住 SKU 行,读到的价就是本事务里要写入 order_items.unit_price 的价。

和「唯一索引」的分工

手段挡住什么挡不住什么
order_id unique一单两张券两单抢同一张全局限额券
部分唯一 (coupon_id, user_id) WHERE status IN ('HELD','COMMITTED')同一人同时占两笔两个人各占一次、打满限额
SELECT ... FOR UPDATE 再 count全局限额超卖—(代价:抢同一张券的下单排队)

锁解决竞态,约束解决「即使锁漏了也不能脏」。两边都要,不是二选一。

事务结束锁才放

锁跟事务走,不跟某句 SQL 走。SELECT FOR UPDATE 之后无论再 INSERT 几张表,锁一直持有到 COMMIT/ROLLBACK。所以必须把「锁券 → 检查额度 → 写核销 → 写订单」放进同一个事务,不能锁完就提交再去写订单——提交的瞬间锁没了,窗口又打开。

这就是文档把下单四步写进同一事务的原因。支付回调是另一笔事务:此时待支付订单已存在,做的是验签、改 PAID、核销改 COMMITTED、写拥有权。两笔事务靠订单状态机衔接,不是一笔从头锁到支付成功。

ORM 在这里只做一件事

概念上你要发出的就是:

BEGIN;
SELECT * FROM coupons WHERE id = $1 FOR UPDATE;
SELECT count(*) FROM coupon_redemptions WHERE ...;
INSERT INTO coupon_redemptions ...;
INSERT INTO orders ...;
COMMIT;

Prisma 的 prisma.coupon.findUnique 不会FOR UPDATE,所以要 $queryRaw。Drizzle 的 .for("update") 就是把这四个字拼进 SQL。差异只在这句锁能不能用类型安全的 API 写出来,不是锁的含义不同。


4. Drizzle 怎么知道迁到哪:和 Prisma 一样是「已应用日志」,不是版本号

两边都没有「当前是 v17,目标是 v19」这种单一版本号。都是:仓库里有一份有序列表,库里有一张已应用记录,差集就是待跑的文件。

Prisma

  • 仓库:prisma/migrations/20240910_xxx/migration.sql 一堆目录
  • 数据库:public._prisma_migrations
  • 每行大约是:迁移名、checksum、applied 时间、成功/失败

migrate deploy:读文件夹顺序 → 查 _prisma_migrations → 没出现过的按序执行 → 插入一行。

Drizzle

两份账要对上:

1. 仓库里的账(源码,跟着 git 走)

drizzle/
├── 0000_initial.sql
├── 0001_add_coupons.sql
└── meta/
    ├── _journal.json      ← 有序列表
    ├── 0000_snapshot.json
    └── 0001_snapshot.json

_journal.json 类似:

{
  "version": "7",
  "dialect": "postgresql",
  "entries": [
    { "idx": 0, "tag": "0000_initial", "when": 1709553600000 },
    { "idx": 1, "tag": "0001_add_coupons", "when": 1709640000000 }
  ]
}

idx / tag 决定顺序和文件名。snapshot 是当时 schema 的 JSON 快照,下次 generate 拿来 diff,运行迁移时不看 snapshot

2. 数据库里的账(这台库已经跑过哪些)

默认不在 public,而在独立 schema:

drizzle.__drizzle_migrations

列大致是:idhash(那份 .sql 的哈希)、created_at

drizzle-kit migrate 实际步骤:

  1. 连库,没有表就建 drizzle.__drizzle_migrations
  2. _journal.json,按 idx 得到应存在的迁移列表
  3. SELECT hash FROM drizzle.__drizzle_migrations
  4. journal 里有、表里没有的,按序执行对应 .sql
  5. 成功则插入一行 hash

所以它知道「迁到哪」的方式是:journal 的最后一条,且这条的 hash 已在 __drizzle_migrations 里。 不是比较一个整数版本。

对照

PrismaDrizzle
库内日志表public._prisma_migrationsdrizzle.__drizzle_migrations(可改)
仓库清单迁移文件夹名meta/_journal.json + 000x_*.sql
如何认「同一条」文件夹名 + SQL checksumSQL 文件 hash
待跑集合文件夹有、表里没有journal 有、表里没有
生产migrate deploydrizzle-kit migrate

本地库跑过 00000001,同事新提交 0002,你 pull 后再 migrate:表里只有两条 hash,journal 有三条,于是只跑 0002。另一台空库:表是空的,三条全跑。和 Prisma 一样。

注意:改已经应用过的 .sql 会让 hash 对不上,两边都会报历史被改。正确做法是再 generate 一条新迁移,不要改旧文件。push 不走这张日志表,所以生产禁用。

"Prisma 里 skus 不是表名,是 Model 上声明的关系字段"

model Product {
  id   String       @id
  skus ProductSku[]   // ← include 用这个名字
  @@map("products")
}

model ProductSku {
  id        String  @id
  productId String  @map("product_id")
  product   Product @relation(fields: [productId], references: [id])
  @@map("product_skus")   // ← 真正的表名
}

也就是说 model 是 Prisma 自己虚构的一层东西,本身和数据库结构是没关联的吗,你 products 表怎么可能物理上和 product_skus 关联起来呢

AI 助手

不是。Model 对应的是表;和库没关系的,只有那些没有列、只给 Client 用的关系字段

物理关联靠的是外键,不是 skus 这个名字。


库里实际长什么样

Postgres 里只有表和列,没有 skus 这个东西:

products
  id          uuid PK

product_skus
  id          uuid PK
  product_id  uuid  NOT NULL  →  REFERENCES products(id)

两张表能「连上」,是因为 product_skus.product_id 存的就是 products.id。这是数据库自己的约束,跟 Prisma、Drizzle 都无关。没有这列,ORM 再怎么写关系也关联不起来。


Prisma 在这上面叠了两层

model Product {
  id   String       @id
  skus ProductSku[]          // ③ 虚拟字段:不是列
  @@map("products")          // ① 这张表
}

model ProductSku {
  id        String  @id
  productId String  @map("product_id")   // ② 真列
  product   Product @relation(fields: [productId], references: [id])  // ③ 虚拟 + 外键声明
  @@map("product_skus")                  // ① 那张表
}
编号写在 schema 里库里有没有作用
model + @@mapProductproducts有,就是表Model 不是虚构表,是表的 ORM 名字
② 标了 @map 的标量字段productIdproduct_id有,就是列真正存关联值的地方
③ 关系字段skusproduct没有对应列只给 Client 用:include: { skus }product.skus

所以:

  • model Product ↔ 表 products(有关联)
  • skus ProductSku[]不是列(这一层才是 Prisma 虚构的)
  • 物理关联 = product_skus.product_id 指向 products.id

@relation(fields: [productId], references: [id]) 的意思是:用 SKU 表上已有的 product_id,去对 商品表的 idmigrate 时 Prisma 会据此建外键,例如:

ALTER TABLE product_skus
  ADD CONSTRAINT product_skus_product_id_fkey
  FOREIGN KEY (product_id) REFERENCES products(id);

include: { skus } 只是告诉 Client:「用这条外键,把 SKU 查回来,塞进 JS 的 skus 数组」。查询时 Prisma 知道去 product_skusWHERE product_id = products.id。数组名 skus 可以改成 variants,表结构和外键不变。


一对多为什么外键在「多」的那边

一张商品对应多条 SKU,所以 product_id 只能放在 product_skus 上。products不能有一列叫 skus——一列存不下多行。

products.id = "aaa"
                ↑
product_skus:  { id: 1, product_id: "aaa", name: "标准版" }
               { id: 2, product_id: "aaa", name: "专业版" }

Prisma 在 Product 上写 skus ProductSku[],只是从「一」的这边反向导航到「多」。库里的箭头始终是 SKU → Product。

Drizzle 一样:表是真的,外键是真的;relations() 里的 skus 同样只是 TS 里的导航名,不会多出一列。