ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Civitai 数据库访问层解析:@civitai/db 的 Prisma 与 Kysely 双入口设计

Civitai 数据库访问层解析:@civitai/db 的 Prisma 与 Kysely 双入口设计 Civitai 数据库访问层解析civitai/db 的 Prisma 与 Kysely 双入口设计【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai导读本文围绕 Civitai 仓库中的civitai/db包packages/civitai-db/README.md展开深入讲解该包如何在单一封装内同时提供Prisma 读写客户端工厂与独立的 Kysely 客户端构建器并将连接池调优、SSL 处理、pg 类型解析器配置集中收敛避免各个消费应用重复推导。读完本文你将掌握如何把该包接入 Next.js / Vite / SvelteKit 应用、两种入口civitai/db与civitai/db/kysely的取舍、Prisma 入口的完整环境变量清单、Kysely 入口的读/写拆分模式与单客户端模式以及 cnpg SSL 证书、NUMERIC/INT8 类型解析等关键坑位的源码级原理。包定位PostgreSQL 访问的收敛层civitai/db是 Civitai 仓库中的一个 workspace 包其定位在 README 开头一句话讲得很清楚PostgreSQL access for Civitai apps—— 它既是一个 Prisma 读写客户端工厂也是一个独立的 Kysely 客户端构建器连接/连接池调优和类型解析器配置都集中在这里消费应用无需重复推导。从 packages/civitai-db/package.json 可以看到其依赖结构civitai/db-schemaworkspace 同级包提供生成的 Prisma client 与 Kysely 类型kysely^0.28.7与pg^8.11.3Kysely 入口的运行时依赖zod^4.0.17环境变量校验prom-client^14.2.0连接池获取延迟指标。包以raw TS形式发布main: ./src/index.ts没有编译产物因此消费方必须自行转译。这也是 README 中Add to an app一节反复强调配置 transpile 的原因。接入应用依赖声明与转译配置在 package.json 中声明依赖// package.json civitai/db: workspace:*, civitai/db-schema: workspace:* // peer: the generated schema/types注意civitai/db-schema是 peer 依赖的角色——它提供生成的 schema 与类型由消费方直接引用。转译配置因为包发货的是原始 TSNext.js—— 在next.config.ts中添加transpilePackages: [civitai/db, civitai/db-schema]Vite / SvelteKit—— 在vite.config.ts中添加ssr: { noExternal: [civitai/db, civitai/db-schema] }传递依赖的约定pg和kysely是civitai/db的依赖会传递给消费应用。README 明确警告不要把它们加到应用的 dependencies 中除非应用直接 import 它们例如import { sql } from kysely。这是 monorepo 中常见的依赖收敛策略——谁真正用到谁才声明。两个入口的设计Prisma 与 Kysely 各司其职civitai/db的 package.json 暴露了两个子路径导出见 packages/civitai-db/package.jsonexports: { .: ./src/index.ts, ./kysely: ./src/kysely.ts }README 用一张表对比了两个入口ImportReturnsEnvUse whencivitai/db(createPrismaClients){ dbRead, dbWrite }Prisma clientsRequiresthe full DB env set (below)App uses Prismacivitai/db/kysely(createKyselyClientsDB){ dbRead, dbWrite, pool }or{ db, pool }Kysely clients —poolis the primary pg poolNone— connection config is explicitApp uses Kysely (lighter; no Prisma engine)关键设计点是/kysely子路径只依赖kyselypg从不引入 Prisma见 src/kysely.ts 顶部注释所以 Vite/SSR 应用可以只引入它而不会把 Prisma engine 拖进包体。从 src/index.ts 可以看到主入口重新导出了全部模块export * from ./env; export * from ./client; export * from ./db-helpers; export * from ./kv-helpers; export * from ./kysely; export * from ./lag;这意味着即便只import civitai/db也会把kysely.ts的模块代码带上——这正是 README 强调仅导入civitai/db不会翻转全局 pg 类型解析的原因详见后文 Gotchas 一节。Prisma 入口环境变量与客户端工厂完整环境变量清单来自 src/env.tsloadDbEnv()由createPrismaClients调用使用 zod 对process.env做整体校验schema.safeParse校验失败会抛出带格式化错误信息的异常。必需变量变量说明DATABASE_URL主库连接串zod 校验为合法 URLDATABASE_REPLICA_URL只读副本连接串NOTIFICATION_DB_URL通知库连接串当前可选见下NOTIFICATION_DB_REPLICA_URL通知库副本连接串可选README 中列出的可选调优变量及其默认值与 src/env.ts 的 zod schema 完全一致变量默认值说明DATABASE_POOL_MAX20连接池最大连接数DATABASE_CONNECTION_TIMEOUT0连接超时毫秒0 表示不限DATABASE_POOL_IDLE_TIMEOUT30000空闲连接回收超时毫秒DATABASE_SSLtrue是否启用 SSLDATABASE_REPLICA_LONG_URL无长连接副本串可选DATAPACKET_DATABASE_RO_URL无datapacket 只读库可选APPS_DATABASE_URL无apps 库可选NOTIFICATION_POOL_MAX无通知库池上限可选DATABASE_READ_TIMEOUT/DATABASE_WRITE_TIMEOUT无读写超时可选IS_DATAPACKETfalsedatapacket 模式开关PODNAME无Pod 名称LOGGING空逗号分隔的日志开关数组LOGGING支持逗号分隔字符串或数组见commaDelimitedStringArray预处理器可取值包括prisma:query、prisma:info、prisma:warn、prisma:error、prisma-slow、prisma-showparams、prisma-slow-write、prisma-slow-read等驱动着 src/client.ts 中的日志行为。需要留意一个仓库演进细节README 与环境 schema 注释都指出NOTIFICATION_DB_URL/NOTIFICATION_DB_REPLICA_URL现在实际上是可选的——只有 notifications 应用apps/notifications会直连通知库并通过createClients读取process.env主应用monolith已不再要求它们。loadDbEnv()是惰性 记忆化的src/env.ts模块加载本身不触碰process.env只有工厂真正调用时才做校验且只解析一次后缓存——因此裸 import构建、脚本、测试永远不会抛错。createPrismaClients 的实现要点src/client.tsexport type CreatePrismaClientsOptions PartialDbConfig { onSlowQuery?: (e: { query: string; duration: number; target: read | write }) void; }; export function createPrismaClients(options: CreatePrismaClientsOptions {}): PrismaClients { const { onSlowQuery, ...envOverrides } options; const config { ...loadDbEnv(), ...envOverrides }; const singleClient config.replicaUrl config.databaseUrl; ... }几个值得展开的实现细节单客户端折叠当DATABASE_REPLICA_URL DATABASE_URL时singleClient为 truedbRead直接复用dbWrite不创建第二个客户端src/client.ts。读/写分流写客户端使用config.databaseUrl读客户端使用config.replicaUrl通过datasources: { db: { url: dbUrl } }注入。慢查询结构化遥测onSlowQuery回调用于替代旧版的logToAxiom由应用注入。当LOGGING含prisma-slow-write/prisma-slow-read时对应客户端注册query事件监听只有执行时长 ≥ 2000ms 的查询才会被处理先把$X参数占位符替换成真实值带负向前瞻(?!\d)避免误替换$11非生产环境直接console.log生产环境交给onSlowQuery上报src/client.ts。prisma-showparams当LOGGING含该值时注册query事件把所有查询含参数内联后的 SQL打印到 stdout便于复制粘贴去 EXPLAIN 调优。Kysely 入口读/写拆分与单客户端模式参考实现spoke 应用的标准用法README 给出的是 moderator 应用的写法参考实现见 apps/moderator/src/lib/server/db.ts仓库中该文件与 README 示例几乎逐字一致import { createKyselyClients } from civitai/db/kysely; import type { DB } from civitai/db-schema/kysely; export const { dbRead, dbWrite } createKyselyClientsDB({ connectionString: process.env.DATABASE_URL, replicaConnectionString: process.env.DATABASE_REPLICA_URL, sslNoVerify: true, // cnpg pooler self-signed cert; see gotcha });三种返回形态src/kysely.tscreateKyselyClientsDB通过重载支持三种形态读/写拆分默认{ dbRead, dbWrite, pool }—— 主库池primary pool驱动dbWrite副本池驱动dbRead。当同时传入readPool或replicaConnectionString时创建两个池否则dbRead复用主库。单客户端{ db, pool }—— 传入singleClient: true折叠为单一 Kysely 实例适用于单库应用如 auth hub或需要 read-your-writes 的流程。预建池直接传pool/readPool例如主应用自己的getClient()池获得完全控制——预建池会原样透传不做 SSL 重写也不套默认池参数。返回的 pool 是主库池pool是驱动dbWrite的主 pg 连接池被交还给调用方用于需要 pool 而非 Kysely 实例的启动期工作——registerEnumArrayTypeParsers就是目前唯一存在的用例。原因注释写得很直白Kysely 不暴露其 dialect 内部的 pool应用如果自己重建就会重复推导本工厂拥有的 SSL 与池尺寸配置。连接池默认值一次有意的加固src/kysely.tsconst config: PoolConfig { max: 20, idleTimeoutMillis: 30_000, connectionTimeoutMillis: 5_000, ...poolConfig, // caller-supplied values win };源码注释揭示了这组默认值背后的故事pg 自身对connectionTimeoutMillis的默认值是不设等价于 0 永远等待一旦连接池被耗尽后续每个调用方都会无限排队而非报错——一个池问题会表现为无界挂起而不是失败。因此本工厂刻意把默认值设为5000ms 的有限超时让问题在调用点快速失败。而max: 20/idleTimeoutMillis: 30000与同仓库的createPool工厂保持一致。调用方显式传入的值优先poolConfig最后展开。这组默认值被测试 kysely.pool-defaults.test.ts 牢牢钉住。该测试用vi.hoisted 记录型RecordingPool子类收集每个被构造的池实例直接断言其options只传连接串 →max: 20、idleTimeoutMillis: 30_000、connectionTimeoutMillis: 5_000显式传max: 3等 → 覆盖默认值钉住展开顺序调用方配置必须在默认值之后展开副本池同样应用默认值预建池不被触碰、工厂不再自建池sslNoVerify: true时主/副本两个池的连接串都必须含sslmodeno-verify且默认值不被绕过——这个用例同时钉住了一个顺序依赖sslNoVerify重写必须发生在config快照poolConfig.connectionString之前否则主池会静默地以未重写的连接串连接在生产中被自签名证书拒绝测试注释称之为dead in production。空闲连接守护工厂给每个自建池注册了error监听src/kysely.ts。原因node-postgres 在空闲连接被管理数据库断开时会在 pool 上触发error事件托管库回收空闲连接、网络抖动、笔记本休眠等场景而error是 Node 的特殊事件——没有监听器就会被重新抛出导致进程崩溃或下一个请求失败。池本身会丢弃坏连接这里只是阻止抛出。惰性注册的 pg 类型解析器createKyselyClients每次调用都会执行registerNumericTypeParsers()幂等模块级布尔标志防重types.setTypeParser(types.builtins.NUMERIC, (val) parseFloat(val)); types.setTypeParser(types.builtins.INT8, (val) parseFloat(val)); types.setTypeParser(types.builtins.TIMESTAMP, (val) new Date(val.replace( , T) Z));它把默认返回字符串的NUMERIC/INT8变成 JS number同时把TIMESTAMPoid 1114timestamp without time zone统一按UTC解析。源码注释记录了一次真实事故pg 默认解析器把该列读成本地时间而 db-helpers.ts 注册的是 UTC 解析器两个应用读同一列相差数小时——一次定时促销因此在一个应用看来已开始、在另一个应用看来未开始。这些列按 UTC 时刻写入就必须处处按 UTC 读取。注册是惰性的发生在工厂调用内部而非模块加载时所以仅仅import civitai/dbPrisma 路径永远不会翻转 pg 的全局解析——只有真正构建 Kysely 客户端的调用方才会 opt-in。枚举数组解析器异步启动期必须 awaitsrc/kysely.ts 中的registerEnumArrayTypeParsers(pool)解决一个隐蔽问题pg 对用户自定义枚举的数组类型没有内置解析器——枚举类型获得动态 oidSomeEnum[]列会以原始 Postgres 字面量{a,b}字符串返回而类型声明承诺的是string[]EXPLAIN 测试抓不到这种静默类型错位。标量枚举列没问题纯文本只有数组列中招。实现方式从目录表查询所有枚举类型的数组 oid 并注册内置的 text-array 解析器枚举值就是纯文本text-array 解析恰好正确SELECT typarray FROM pg_type WHERE typtype e AND typarray 0因为需要一次目录往返它是async的必须在启动期、首次枚举数组 select 之前 await。oid 从传入 pool 的数据库读取——在多库进程中同一 oid 可能指向不同类型所以要从主库schema 池注册并且只对共享该 schema 的库主库及其副本执行枚举数组查询。Gotchas三个必须知道的坑README 专门列了一节 Gotchas结合源码我们可以把每个坑讲透1. cnpg SSLsslmoderequire不等于 libpq 的行为node-postgres 把 URL 中的sslmoderequire映射为完整链校验与 libpq 不同会拒绝 cnpg pooler 的自签名证书而且 URL 中的 sslmode 会覆盖独立的ssl选项。解决方案是传sslNoVerify: true工厂内部用forceSslNoVerifysrc/kysely.ts把连接串重写为sslmodeno-verify——SSL 保持开启只是关闭证书链校验。预建池原样透传SSL 配置在构造处负责。注意forceSslNoVerify用的是URL对象操作searchParams只会改写查询参数、保留主机与路径。2. NUMERIC/INT8 → number惰性注册避免全局污染如前述类型解析器在工厂调用时惰性注册而非模块加载时保证 Prisma-only 消费方不会被意外改变全局 pg 解析行为。registerEnumArrayTypeParsers同理一次注册惠及所有 node-postgres 池Prisma独立引擎不受影响。3.pg是传递依赖只在应用直接import pg时才需要显式声明否则交给civitai/db传递。进阶能力包内附带的数据库工具集虽然 README 主要聚焦两个入口但 src/index.ts 的导出清单揭示了包内还有一组围绕 PostgreSQL 的工具它们与DB 访问收敛主题直接相关值得补充db-helpers.ts可取消查询与只读超时查询src/db-helpers.tscreatePool从显式连接串构建增强型 pg 池统一拥有cancellableQuery增强、连接延迟指标包装、SSL 与参数接线。默认max: 20、connectionTimeoutMillis: 0与 Kysely 工厂的 5000 形成对比注释说明这是刻意的Kysely 工厂更严格。默认把连接串改写为sslmodeno-verify除非ssl: false。cancellableQuery返回{ query, result, cancel }三元组。cancel()通过池外的一次性 Client执行SELECT pg_cancel_backend($1)使用连接对象的processID避免额外查询并在finally中end()。测试 db-helpers.cancel.test.ts 记录了一次死锁修复PR #2437旧实现调用第二次pool.connect()来取消而池饱和时每个槽位都被等待取消的查询占着取消请求永远排不上队——池外 Client 是唯一正确做法同时连接超时被限制默认 5000ms保证尽力而为的取消不会永久挂起查询已结束后cancel()是 no-opdone标志由查询自身的.finally()独占负责。createClients从显式连接串构建读/写池对write/read惰性访问器单库时read别名write。这是面向应用的无环境耦合接缝。queryWithTimeout用BEGIN READ ONLYSET LOCAL statement_timeoutCOMMIT把只读查询的超时限定在事务内即使在 PgBouncer 事务池下也成立超时抛 pg 错误码57014query_canceled。注意timeoutMs是字面量插值——PostgreSQL 不接受$1参数化SET LOCAL调用方必须传可信的 JS number。dataProcessor/batchProcessor配合limitConcurrencysrc/concurrency-helpers.ts从主应用 vendored 而来保持包自包含实现游标分批 并发处理 关闭时批量取消的长任务骨架。kv-helpers.tsLSN 与 DB KVsrc/kv-helpers.tsgetCurrentLSN读pg_current_wal_lsn()checkNotUpToDate对比副本replay_lsn判断副本是否追平主库makeDbKV把 JSON 值 upsert 进KeyValue表ON CONFLICT (key) DO UPDATE。lag.ts复制延迟路由原语src/lag.tscreateLagTracker是 DB 读一致性组件写操作markFresh(key)打标读操作isStale(key)判断是否在延迟窗口内需要路由到主库。它保持redis 无关标志存储通过 DI 注入不依赖civitai/redis与pool 无关isStale返回布尔值由调用方决定走哪个池。delaySeconds非正数或 NaN如环境变量解析失败时整体禁用——isStale恒 false、markFresh为 no-op绝不触碰存储、绝不写非法 TTL。测试 lag.test.ts 覆盖了禁用、null store 降级、thunk 惰性解析与记忆化等边界。消费方视角什么时候选哪个入口结合 README 与源码选择依据可以归纳为应用使用 Prisma如主应用 monolith→ 走civitai/db的createPrismaClients依赖完整 env 集享受onSlowQuery慢查询遥测与prisma-showparams调试能力应用使用 Kysely、追求轻量spoke 应用如 moderator→ 走civitai/db/kysely的createKyselyClients零环境耦合、不拖入 Prisma engine、自带池默认值与类型解析修复已有自建 pg 池→ 直接传pool/readPoolcreateKyselyClients完全尊重你的池配置。无论哪条路civitai/db都确保连接池调优、SSL 处理和类型解析器这些推导一次就够了的配置只存在于一处——这正是该包存在的意义也是各 spoke 应用不再各自重造轮子的原因。【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进