Civitai 数据库 Schema 契约包@civitai/db-schema:单一事实来源、类型分发与 Schema 漂移检测实战
【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai
本文是 Civitai 仓库中@civitai/db-schema包的技术指南。该包是整个多应用仓库的schema contract(模式契约)层:它集中承载由 Prisma 生成的客户端与类型、由 prisma-kysely 生成的完整DB类型,并附带一个能对照真实数据库pg_catalog检测"声明与实现漂移"的只读检查器。读完本文,你将掌握该包的四个导出入口的用途与取舍、如何在 Next.js/Vite 应用中正确接入并完成生成流程,以及如何用 drift 检测器与drift:gate门禁持续审计 Prisma schema 与实际数据库之间的一致性。
为什么需要独立的 Schema 契约包
在 packages/civitai-db-schema/README.md 的开篇,这个包被明确定义为schema contract package:
每个其他包和应用都针对它来做类型声明,而不是直接导入
@prisma/client,从而让 schema 拥有单一事实来源(single source of truth)。
这与仓库的 monorepo 结构直接相关:apps/下有 auth、creator-studio、moderator、notifications、storage 等多个应用,packages/下有 civitai-db、civitai-db-queries、civitai-buzz 等十余个共享包。若每个模块都直接import { PrismaClient } from '@prisma/client',schema 一旦演进,所有引用点都要跟着散落更新,极易产生不一致。
@civitai/db-schema把"生成的 Prisma 客户端 + 所有模型类型"集中为唯一出口,其他模块一律经由它取类型:
- Prisma 路径:运行时拿到
Prisma、PrismaClient; - Kysely 路径:类型层面拿到完整的
DBschema 类型(纯类型,无运行时开销); - 枚举与模型子路径:按需取生成的枚举对象与模型接口。
从 src/index.ts 可以看到入口实现的两个关键决策:
// eslint-disable-next-line import/no-extraneous-dependencies export { Prisma, PrismaClient } from '@prisma/client'; // eslint-disable-next-line import/no-extraneous-dependencies export type * from '@prisma/client';@prisma/client刻意不作为本包的显式依赖声明。源码注释解释了原因:一旦声明,prisma generate会在 workspace 根目录尝试自动pnpm add @prisma/client并失败;目前依赖根目录的 hoisting 解析,根治方案是让自定义 generator 输出到../generated/client后从./generated再导出。- 刻意不用
export *导出 CommonJS 形态的@prisma/client。Turbopack 无法通过运行时export *静态枚举一个 CJS 模块的导出,会对每次导入报警告。因此运行时只显式再导出Prisma与PrismaClient(真正有运行时代码的部分),其余全部用export type *走类型通道——零运行时代码,同样的对外表面积,没有告警。
包结构与四个导出入口
包的 manifest package.json 通过exports字段定义了四个子路径出口:
| 导入路径 | 提供内容 | 源码位置 |
|---|---|---|
@civitai/db-schema | Prisma、PrismaClient及全部模型类型(从@prisma/client再导出) | src/index.ts |
@civitai/db-schema/kysely | DB—— 完整的 Kysely schema 类型(纯类型,无运行时) | src/kysely/types.ts |
@civitai/db-schema/enums | Prisma 枚举(值 + 类型) | src/enums.ts |
@civitai/db-schema/models | 模型类型 | src/models.ts |
另外还暴露了两个内部子路径:/kysely/updated-at-tables与/schema-drift。
主入口与三个子路径的分工
index.ts的注释明确说明:生成的枚举对象与模型接口刻意不在此处合并导出(它们的名称相互重叠),而是要求通过子路径分别导入:
import { ... } from '@civitai/db-schema/enums'; import type { ... } from '@civitai/db-schema/models';/enums入口 src/enums.ts 由自定义 Prisma generator 生成,头部注明"do not edit manually"。其形态是as const对象 + 派生类型,例如:
export const ModelType = { Checkpoint: 'Checkpoint', TextualInversion: 'TextualInversion', LORA: 'LORA', Controlnet: 'Controlnet', VAE: 'VAE', LLM: 'LLM', // ... } as const; export type ModelType = (typeof ModelType)[keyof typeof ModelType];这样既可在运行时用ModelType.Checkpoint作为值,也能在类型层面约束字段。文件涵盖 120+ 个枚举,从模型类型、内容审核(NsfwLevel、ReportReason、TagSource)到支付(Currency、PaymentProvider、CashWithdrawalStatus)、漫画/挑战等新业务域,与 prisma/schema.full.prisma 一一对应。
/models入口 src/models.ts 由prisma-generator-typescript-interfaces生成,提供每个表的接口形态(含关系字段),例如User接口承载了数十个一对多关系。/kysely入口 src/kysely/types.ts 则面向 Kysely 查询构建器:定义Generated<T>与Timestamp工具类型后,为每张表生成{ id: Generated<number>; ... }形态的表类型,并内联导入全部枚举类型——它只用于createKyselyClients<DB>()这类类型参数,不产生任何运行时字节。
在应用中使用该包
1. 添加依赖
在目标应用的package.json中加入 workspace 依赖:
// package.json "@civitai/db-schema": "workspace:*"2. 与消费者一起转译
由于包内源码是 TypeScript 而非编译产物,需要与其主要消费方@civitai/db一起被宿主转译:
- Next.js:在
next.config中配置transpilePackages; - Vite:在
ssr.noExternal中列出该包。
3. 导入
import type { DB } from '@civitai/db-schema/kysely'; // 供 createKyselyClients<DB>() 使用 import { Prisma, PrismaClient } from '@civitai/db-schema'; // Prisma 路径README 特别提醒:通常不需要直接添加本包——它会作为@civitai/db的伴随依赖(peer)被带入;只有当你自己需要导入DB类型或枚举时才显式声明它。
4. 生成流程
- Prisma 主入口需要已生成的客户端:执行
pnpm run db:generate(workspace 根脚本); /kysely与/enums子路径是纯类型/值,在 type-check 阶段无需任何生成步骤;- 本包不需要任何环境变量、不建立任何数据库连接——它只是类型与契约。真正的连接管理位于
@civitai/db包(README 的 Gotchas 一节与package.json的依赖列表相互印证:本包只依赖kysely与pg)。
Drift Detector:对照真实数据库审计 schema 一致性
该包最具特色的部分位于 src/schema-drift/,其详细文档见 src/schema-drift/README.md。
背景问题很直接:schema 文件不等于数据库。本项目迁移按环境手工执行,因此"schema 文件里声明了、数据库里却没有"的状态可能长期存在而无人察觉。这个工具把差距变成可数的数字。
运行方式
pnpm --filter @civitai/db-schema drift # 文本报告 pnpm --filter @civitai/db-schema drift --json # 机器可读 pnpm --filter @civitai/db-schema drift --verbose # 附加被跳过的模型列表连接串来自环境变量DATABASE_URL——工具不内置任何关于数据库位置的信息,也不应该内置。它是严格只读的:每条语句都是对pg_catalog的SELECT,从不写库、从不执行 DDL、从不应用迁移(见 cli.ts 头部注释)。
完整 CLI 参数(来自cli.ts的USAGE):
| Flag | 作用 |
|---|---|
--schema <path> | 指定要读取的 Prisma schema(默认:本包的prisma/schema.full.prisma) |
--catalog <path> | 读取先前捕获的 catalog JSON 而非连接数据库 |
--dump-catalog | 将 catalog 以 JSON 输出后退出(与--catalog配对使用) |
--db-schema <name> | 指定要内省(introspect)的 Postgres schema(默认public) |
--json | 以 JSON 输出漂移报告 |
--verbose | 在文本报告中包含被跳过的模型列表 |
--strict | 发现任何漂移时退出码为 1;默认总是退出 0 |
--strict刻意设计为可选项:当前数据库真实存在一段漂移 backlog,若门禁在任何发现时都失败,会天天红、最终人人点过(permanently-red gate 只会教会大家点掉它)。在--strict下,无法比较的 referential action 同样会使运行失败——"未测量"不等于"干净"。
退出码 2 不可绕过:一次什么都没比较的运行,会打印出与健康数据库完全相同的"干净"页面——这正是--db-schema拼错、DATABASE_URL错误或角色无 catalog 可见性时的表现。CLI 会自行检查覆盖率并以 2 退出,而不是报告一个安慰性的零。
检查什么(五类漂移)
| 检查项 | Schema 侧 | 数据库侧 |
|---|---|---|
| 外键存在 | 每个拥有侧的@relation(fields: […], references: […]) | pg_constraint中contype = 'f'且有序列元组相同 |
| 引用动作 | onDelete/onUpdate(显式或默认) | confdeltype/confupdtype |
| 列存在 | 每个标量字段 | 表上的pg_attribute行 |
| 可空性 | 字段可选性(?) | pg_attribute.attnotnull |
| 唯一性 | @unique、@@unique | pg_index中indisunique且无indpred |
@@map/@map全程解析为真实的表名与列名。
判断细节中的几个"坑"(源码注释的精华)
- 存在但动作错误是独立缺陷类:约束存在,看似不缺,但数据库强制规则与 schema 承诺不一致,
ON DELETE与ON UPDATE都会被比较; - Prisma 默认
onDelete不是Cascade:可选关系默认SetNull,必选关系默认Restrict——在必选关系上真相恰恰相反:父级删除会被拒绝而非传播; references:是读出来的,从不假设为id:多数关系引用id,但也有引用projectId,position、serialId、userId、type、blockInstanceId的;- 两种
@relation写法都被接受:位置式@relation("X", fields: …)与命名参数式@relation(name: "X", fields: …)。此前只处理前者时,六个反向写法的关系既不出现在计数器里也不产生 finding——工具对外键报告"干净",实际上从未看过它们; - 声明的列在表中不存在,是独立 finding,而非可空性不匹配(Prisma 读取时会直接报 "column does not exist");
- 块属性在去除注释后读取:
@@map/@@ignore/@@unique与模型体匹配,避免回滚遗留的// @@ignore静默跳过整个模型; - 映射到视图或不存在的表的模型会被跳过(与
@@ignore模型一起被计数和列出,不视为漂移——没有表可供约束存在); - 部分唯一索引不算数:
WHERE子句索引只对其命中的行强制唯一,@unique是对每一行的承诺; - 表达式索引被丢弃而非按名匹配:
lower(email)索引不能伪装成email上的索引; - 列聚合以
text[]而非name[]选择:node-postgres 未注册name[]的数组解析器,array_agg(a.attname)会以字面量字符串"{projectId,position}"到达 JS——assertParsedArray会拒绝未解析的列表; - 统一答复的 catalog 读取会被大声拒绝:
NOTNULL是 Postgres 保留字,SELECT a.attnotnull notnull会解析为后缀IS NOT NULL运算符,每行都返回常量true,把每个可空列都读成NOT NULL——曾因此捏造出 626 个单向 finding。assertCatalogSanity现在直接让这类运行失败。
不检查什么(缺省不等于干净)
- check 约束
- 列默认值
- 列类型(含长度、精度与
@db.*原生类型) - 枚举值与枚举成员
- 非唯一索引、索引方法与排序
- 主键(
@id/@@id)——唯一性检查只覆盖@unique/@@unique,本 schema 有 105 个@@id声明无一被验证 - 程序化对象(视图、函数、触发器、规则)——例如
BountyRank_Live视图实际发射了其提交定义没有的五个commentCount列 - 数据库中多出但 schema 缺失的列与表(反向方向才被检查)
- 行级安全、分区边界
- 外键引用表是否与 schema 命名一致(只匹配被约束的列元组)
内部结构:纯函数驱动的四层流水线
| 文件 | 职责 |
|---|---|
| parse-prisma-schema.ts | Schema → 模型、字段、映射、关系、唯一声明 |
| catalog.ts | pg_catalog→DbCatalog(唯一与数据库对话的文件) |
| compare.ts | (schema, catalog) → findings,纯函数:无 I/O、无时钟 |
| report.ts | Findings → 文本报告 |
differ 保持纯净(pure),因此可以从 fixtures 驱动测试,包括刻意损坏的 fixture。cli.ts只负责参数解析与装配。
drift:gate:PR 级别的漂移增量门禁
除了交互式报告,还有 CI 半自动门禁(见 gate-cli.ts):
pnpm --filter @civitai/db-schema drift:gate # 判定;发现新的 enforced 漂移时退出 1 pnpm --filter @civitai/db-schema drift:baseline # 接受当前 findings设计核心:永不打开数据库连接
gate-cli.ts没有任何数据库代码路径——它只把 schema 与已提交的 catalog 快照比较,不存在任何能让它持凭据、解析主机名、把凭据打印进公开日志的 flag。这是硬性要求而非便利选择:该仓库是公开的,CI 日志也是公开的,pg客户端在这里甚至没有被 import。
两级严重性:为什么这个切分是结构性的
本项目迁移按环境手工应用,schema 声明领先数据库是正常中间态而非缺陷。区分标准不是品味排序,而是:该 finding 涉及的数据库表面是否已存在?
| 级别 | 判定 | 门禁行为 |
|---|---|---|
| enforced | 列已在 catalog 中、约束却缺失 | 使检查失败 |
| pending | 列不在 catalog 中,schema 领先于它 | 仅警告 |
missing-column按构造恒为 pending(列缺失本身就是 finding);nullability与uniqueness按构造恒为 enforced(differ 只对找到的列发出);missing-foreign-key是唯一可二选一的种类——只要其任一被约束列不在 catalog 中即为 pending。
级别可上升,且上升会使检查失败:被接受为 pending("该列尚不存在")的 finding,在迁移落地却未带上约束的那一刻就变得可强制。指纹不变,因此门禁比较当前级别与 baseline 记录的级别,报告pending -> enforced为失败——否则升级会被计入 matched 数量并以 0 退出。drift:baseline会打印每次吸收的升级,使其进入 recapture 提交的日志而非无声消失。
为什么用 baseline 而不是--strict
--strict在任何 finding 时失败,而main上现有 63 个 finding。天天红的门禁会在一周内被关掉。drift-baseline.json(位于 src/schema-drift/drift-baseline.json)将这 63 个记录为已接受,门禁只报告不在其中的内容。baseline 已提交,因此"接受新漂移"成为可评审的行为:门禁会给出确切命令,产生的 diff 让评审者精确看到这次改动放弃了哪个约束。
指纹刻意排除declared/actual散文,但把两项故意折叠进来——因为它们是 finding 本身而非关于它的散文:
- 可空性方向:把字段翻转为必填却对着 NULLABLE 列,不能继承旧条目的通过;
- 缺失外键的引用表:把关系从
Image改指向Post——同模型、同字段、同被约束列——同样不能继承。
baseline 还被校验为与其捕获时的 catalog 匹配:针对不同快照测量的 baseline 描述的是不同的数据库,门禁会以退出码 2 拒绝比较。
门禁能抓什么、不能抓什么
数据库侧是冻结快照,因此门禁只看到一个方向:
| 说明 | |
|---|---|
| 能抓 | schema 编辑承诺了被捕获数据库没有的东西 |
| 抓不到 | 数据库侧的任何动作——生产环境删除的约束对它不可见 |
| 会退化 | 捕获之后新建的列上的漂移,只能永远 warn-only |
第三行最值得警惕:这不是一次 recapture 就能修复的 bug,而是与冻结工件比较的固有行为——快照越旧,阻塞性覆盖越少而检查仍然绿色。因此每次运行 verdict 都会打印快照捕获日期与年龄,超过 90 天会大声警告。这个工具诚实的名字是schema-edit gate(schema 编辑门禁);定期 recapture 快照才能让它更接近真正的 drift gate。
另外要明确:main有分支保护但没有required_status_checks,红色门禁今天并不会阻止合并——它是响亮、可评审的信号而非联锁。
测量数据:对照提交快照(2026-08-05)
__tests__/fixtures/catalog-production-2026-08-03.json是生产环境约束 catalog 的点位捕获(仅 schema 元数据:表/列名、可空性、外键与唯一索引列元组,无行数据、无运行位置信息)。用--catalog复现:
pnpm --filter @civitai/db-schema drift \ --catalog src/schema-drift/__tests__/fixtures/catalog-production-2026-08-03.json当时的输出概览:
declared owning-side relations : 509 checked against the database : 448 skipped (view / absent table) : 61 MISSING foreign key : 40 wrong referential action : 0 <- "未测量",不是 "干净" MISSING column : 18 nullability checked : 2348 nullability drift : 13 uniqueness declarations checked: 122 missing unique index : 1值得注意的几点:referential action 的0是"未测量"——该快照早于该检查项,没有ON DELETE/ON UPDATE数据,408 个可比较外键全部报告为not comparable。真实运行会测量它们:首次运行发现45 个,全部是ON UPDATE(声明Cascade、数据库NoAction),ON DELETE零不匹配;由于id从不更新,它们实践中是惰性的。
可空性漂移从工具上线时的 246 降到 13:#3592把七个*Rank家族列标记为可选以匹配数据库,一次消灭了 235 个——这是真实的修复行动,也解释了为何当时无人察觉:CI 并未运行该包的测试套件,直到 packages 被接入 CI。
测试与验证体系
pnpm --filter @civitai/db-schema test测试策略的核心思想(见 src/schema-drift/tests/):一个什么都不返回的检测器,与一个没接线的检测器无法区分,直到你看着它完成两种行为:
- 阴性对照:对齐的 (schema, catalog) 对被比较,断言运行静默;随后从 catalog 移除一个外键,断言同一比较精确报告该键;
- 对零的正向对照:对齐运行还断言非零的已检查计数(4 个关系、3 个唯一声明、>15 列),使空的 finding 列表不可能来自"什么都没比较"的运行;
- CLI 层的同一对(
cli.test.ts以进程方式运行真实入口):空 catalog 必须退出 2 并提示 "not trustworthy",覆盖充分的 catalog 必须退出 0——没有第二个断言,第一个可能因任何原因通过。
catalog.test.ts用假查询驱动器驱动读取器:覆盖行解码(动作码、有序元组、未解析数组、表达式索引),并钉死关键的 SQL 谓词(indpred IS NULL、relkind IN ('r','p')、WITH ORDINALITY、attname::text)。文档明确提醒:它不执行 SQL,绿色套件不等于查询在真实服务器上行为正确——请通过实际运行工具来验证。
CI 侧,根目录vitest.config.mts的Package unit testsjob 通过pnpm run test:packages:run运行所有包套件,并用台账脚本断言每个有 vitest 配置和测试文件的工作区包都出现在结果中且至少执行了一个非跳过测试——因为--project匹配空也会退出 0,glob 停止解析也会退出 0,自我跳过的套件同样退出 0。该 job 并非联锁(无required_status_checks),但它让红色"可见"而非"被忽略"。
接入与使用要点速查
- 依赖:
"@civitai/db-schema": "workspace:*";通常随@civitai/db作为 peer 带入,仅在自行导入DB类型或枚举时显式添加; - 转译:Next 配
transpilePackages、Vite 配ssr.noExternal,与@civitai/db一同处理; - 生成:Prisma 入口需要
pnpm run db:generate;/kysely、/enums无需生成即可通过类型检查; - 环境:导出面不需要任何 env、不连库;仅 drift 检测器读取
DATABASE_URL; - 漂移审计:
drift(文本/JSON/verbose)用于人工巡检,--strict用于有干净基线可守的调用方,--catalog/--dump-catalog支持离线快照比对; - CI 门禁:
drift:gate只拦截"在数据库已存在的列上新增 enforced 漂移"与pending -> enforced升级,drift:baseline将接受行为变成可评审的提交差异; - 快照保鲜:recapture 快照(
drift --dump-catalog)与刷新 baseline 应在同一提交内完成,并留意新纳入范围的 45 个 referential-action finding 需逐条分类处理。
注意事项:本包是纯契约层——没有 env、没有 DB 连接、只有类型与值。所有连接与读写都发生在
@civitai/db及其上层服务中;任何把本包当作运行时数据库入口使用的做法都偏离了它的设计边界。
【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考