CivitAI 的 `dbRead`/`dbWrite` 双客户端路由:当服务层在运行时选择数据库客户端时,测试 mock 如何正确拆分 CivitAI 的dbRead/dbWrite双客户端路由当服务层在运行时选择数据库客户端时测试 mock 如何正确拆分【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai本文解析 CivitAI 仓库中dbRead/dbWrite数据库客户端别名拆分的一个特殊场景部分 service 不在源码中固定使用哪个客户端而是在运行时通过「入参传递」或「复制延迟探测」动态选择。读完本文你将掌握如何在共享 mock 迁移shared-mock migration中为这类路由不可读的测试桶确定正确的客户端绑定理解负断言negative assertion为何会静默失效以及如何验证一次拆分是否真正生效——这些方法论对任何采用读写分离架构的测试体系都直接适用。背景共享 mock 迁移中的路由不可读桶CivitAI 的 server 端通过 src/server/db/client 暴露两个 Prisma 客户端别名dbRead读副本与dbWrite主库。大量服务测试用如下方式把两者指向同一个本地 mockvi.mock(~/server/db/client, () ({ dbRead: mockDb, dbWrite: mockDb }));这种一个 mock 同时服务两个客户端的写法在迁移到共享 mock 时需要拆开为被测模块找到形如dbRead.model.method或dbWrite.model.method的调用点把断言绑定到对应客户端。对多数文件读一遍被测模块源码就能完成拆分。但在src/server/services/__tests__的 a–m 字母段分支perf/test-mock-migration-services-a-m中有一批文件的路由决策无法用通常方式从生产源码读出。CivitAI 仓库中 claudedocs/services-a-m-parameterised-client-analysis.md 记录了这次逐案例分析撰写于 2026-08-15桶分类在分支基准提交17f994221e上验证。阻碍读源码即得路由这一常规手段的机制有两个客户端是参数——服务函数接收db选项调用链上只出现db.model.method客户端由复制延迟决定——getDbWithoutLag依据运行时 Redis 状态在dbRead/dbWrite之间二选一。两者叠加后grep dbRead.appBlockPublishRequest.findFirst一无所获且负断言的错误路由不会让测试变红——所以必须逐案例写清。机制一客户端是参数以block-registry.service.ts为例src/server/services/block-registry.service.ts 中有五个调用点按入参选择客户端。文档基于分支基准时引用的行号为:1362、:1819、:1887、:1928与:2032在当前仓库中这些模式仍完整存在行号随后续提交有偏移const db opts.db read ? dbRead : dbWrite; // 多数站点缺省为 dbWrite const db opts?.db write ? dbWrite : dbRead; // 一个站点缺省被反转为 dbRead选定的db会被向下传递例如applyPinnedVersion(live, appBlockId, pinnedVersion, db)。后果是测试实际走哪个客户端是测试调用的事实而不是服务源码的事实。grep 模块名找不到任何dbRead.xxx/dbWrite.xxx的直接拼写路由只能从入口函数的缺省分支推出来。为什么错误路由会静默正断言在调落到另一个客户端时会变红mock 上没有该调用记录但负断言不会。expect(mockDb.appBlockReview.create).not.toHaveBeenCalled()在create被路由到代码从未触碰的客户端时平凡通过——断言没有验证任何东西。这批六个文件共携带 14 个此类负断言因此跑一遍套件根本无法捕获错误猜测。这是整份分析最核心的警示负断言在错误路由下是惰性的inert不是通过的证据。路由决策表六个文件八个入口文档作者最初的判断是这些需要逐测试人工决策可能要长期手工维护。逐文件读完后的修正结论是这里没有测试传递过db选项用db: read/db: writegrep 各文件零命中所以每个入口都落到自己的缺省值而缺省值是固定的、可静态确定的入口不传db选项时的客户端BlockRegistry.resolveBlockInstancedbWriteopts.db read ? dbRead : dbWriteBlockRegistry.applyPinnedVersion继承调用方——从resolveBlockInstance来即dbWriteBlockRegistry.getFeaturedBlocks仅dbRead.$queryRawBlockRegistry.getMarketplaceMetadbRead.appBlock.findUniqueBlockRegistry.setMarketplaceMetadbWrite.appBlock.*upsertAppBlockReviewfindUnique/create/update用dbWritefindMany与第二个findUnique用dbReadsetAppReviewExcludeddbWrite.appBlockReview.updatebustAppRatingCachedbRead.appBlock.findUnique、dbRead.blockUserSubscription.findFirst其中**反转缺省站点是最大的陷阱**四个站点缺省dbWrite唯独一个站点缺省dbRead。任何从前四个学到缺省即写库并套用到第五个的人都会得到一次静默的错误路由。结论是检查具体站点不要相信模式。机制二客户端由复制延迟决定更常见也更隐蔽第二个机制是 src/server/db/db-lag-helpers.ts 中的getDbWithoutLag该文件在仓库中位于第 46 行起export async function getDbWithoutLag(type?: LaggingType, id?: number | string) { if (env.REPLICATION_LAG_DELAY 0) return dbRead; // 生产环境恒走此分支默认 0 if (type undefined || id undefined || id null) { return isHighReplicationLagMode() ? dbWrite : dbRead; } return (await lagTracker.isStale(lagKey(type, id))) ? dbWrite : dbRead; }其设计意图是 read-your-writes写操作通过preventReplicationLag(type, id)在 Redis 中为特定实体打标见同文件 L65-L68读路径若发现该实体刚被写过isStale就切到dbWrite读主库避免读到滞后副本。同文件还提供 Kysely 孪生版getKyselyWithoutLag、批量版getDbWithoutLagBatch以及针对模型/版本双键的preventModelVersionLagBatch。凡是经由getDbWithoutLag触达的查询本 slice 中为model-version.service.getVersionById源码里没有固定客户端常规 grep 的结果是两个都对BOTH。测试环境下的行为陷阱OC-317已被当前仓库修复文档用 标记了一个更深层的问题REPLICATION_LAG_DELAY在 src/env/server-schema.ts 中是z.coerce.number().default(0)键但当时缺席于TEST_ENV_DEFAULTS。于是测试中的规范化 env 读到的是undefined而非0——而undefined 0是false0 0是true。结果是每一条未 mockdb-lag-helpers却走到getDbWithoutLag的测试都会进入生产环境永远不会走的陈旧性分支最终落在 Redis 读取决定的那个客户端上。文档当时还统计了面73 个 zod 默认键中 59 个缺席TEST_ENV_DEFAULTS其中 40 个是数字或布尔默认值undefined与真实默认值不等价。值得对照的是当前仓库的状态src/tests/mocks/env.mock.ts 中TEST_ENV_DEFAULTS现在直接展开...schemaDefaults()——从 schema 自身的.default()推导、再叠加测试覆写注释明确写道/** * The schema-derived base means a key gaining a .default() in the schema * automatically appears here — the hand-enumerated divergence that caused OC-317 * (REPLICATION_LAG_DELAY missing, read as undefined under test) cannot recur. */ export const TEST_ENV_DEFAULTS: Recordstring, unknown { // Schema-derived defaults (every .default() from serverSchema) ...schemaDefaults(), ...也就是说文档记录的机制二缺陷属于共享 mock 层的一次性修复项文档原话Fixing that is a shared-mock change, not a slice change当前仓库已通过默认值从 schema 自动派生的结构性手段消除了复发可能。受影响文件两个是真三个是误报经入口级核实本 slice 中真正不能靠肉眼拆分的文件是两个而不是最初整模块扫描时归入此桶的五个文件入口原因model-version.blue-buzz-purchaseearlyAccessPurchase经由getVersionById其内部是forceWriteDb ? dbWrite : await getDbWithoutLag(…)model-version.purge-by-hashpublishModelVersionById直接调用getDbWithoutLag其余三个是误分类的普通转换model-version.deregister的deleteVersionById只用dbWritemodel-version.service中别处的dbRead拼写属于该测试从未调用的函数model-file.service与model-file-scan.service干脆完全不含getDbWithoutLag。由此提炼出整份分析的方法论核心整模块扫描得出的 BOTH 不是裁决而是一个未回答的问题。真正裁决的是测试实际导入的入口——只有当该入口本身把选择推迟到运行时调用方的db选项或复制延迟探测时文件才属于这一桶仅因大模块里恰好两种拼写都出现不构成理由。另一个 ⚠️ 级结论本 slice 中已经安全转换的文件之所以安全是巧合而非判断。contest-entry-base-model-gate、contest-entry-resource-gate、article-locked-properties、model-locked-properties、model-flag-side-effects与model-version.linked-component全都直接 mock 了~/server/db/db-lag-helpers在任何上述机制生效之前就把客户端钉死了。安全与不安全的转换之间仅以测试是否碰巧 stub 了该模块为界——所以哪些文件转换得干净不能用来反推哪些文件有风险。当前仓库的 src/server/services/tests中仍可看到这批测试对db-lag-helpers与db/client的 mock 并存。逐文件路由结论六个测试文件block-registry.pinned-version.test.ts4 个用例驱动resolveBlockInstance与applyPinnedVersion均不传db选项 →blockUserSubscription.findUnique与appBlockPublishRequest.findFirst走dbWrite。文档同时保留了一条已更正的错误结论作为反面教材最初记录model.findUnique在源码中直接解析为dbRead并于 2026-08-22 更正——resolveBlockInstance在:1362基准提交行号取得客户端参数后下游全部使用这个局部变量包括db.model.findUnique被引用的dbRead.model.findUnique拼写实际位于另一处:2617属于这些测试从未调用的函数。基于错误结论把model/modelVersion路由到dbRead后代码在一个从未被触碰的客户端上读到规范的nullif (!model) return null处处触发——跨两个文件六个测试全部expected null not to be null最终十五处站点改回dbWrite。完整事故记录见 claudedocs/services-a-m-handover.md 的 The red, and the routing table that caused it 一节。这条事故的教训指向本文档本身引用是存在的、看起来是核对过的、但被引用的行不在实际调用路径上——而表格其余部分是正确的这反而让它更容易被信任。核对路由结论必须对照测试实际驱动的调用路径而不是文件里恰好相同的拼写。block-registry.resolve-instance.test.ts27 个用例全部经由resolveBlockInstance不传db选项 →blockUserSubscription.findUnique/.findFirst与platformDefaultBlock.*走dbWritemodel.findUnique与modelVersion.findFirst在源码中拼写为dbRead。 负断言expect(mockDb.blockUserSubscription.findUnique).not.toHaveBeenCalled()必须改绑到mockDbWrite。block-registry.marketplace-meta.test.ts14 个用例三个入口对应不同的客户端getFeaturedBlocks→dbRead.$queryRawgetMarketplaceMeta→dbRead.appBlock.findUniquesetMarketplaceMeta→dbWrite.appBlock.*。这是六个文件中唯一appBlock.findUnique真实出现在两个客户端上的文件——拆分必须跟着每个用例的入口走而不是跟着表名/路径走。 负断言expect(mockDb.appBlock.update).not.toHaveBeenCalled()×3update是dbWrite-only机械路由即可安全处理。block-registry.spend-cap-config.test.ts15 个用例appBlock.update→dbWriteappBlock.findUnique→ 按入口随上述规则。 与上一文件相同的appBlock.update负断言推理相同。appBlockReview.service.test.ts17 个用例upsertAppBlockReview对appBlockReview.findUnique两个客户端都用存在性检查走dbWrite后续读取走dbRead。仅按路径拆分在这里是错的必须按用例断言的是哪一次调用来拆。 appBlockReview.update×2与.create×4的负断言——在upsertAppBlockReview内两者都是dbWrite-only这六条机械路由是安全的。appBlockReview.collaborator-self-review.test.ts11 个用例appBlock.findUnique与blockUserSubscription.findFirst→dbRead经bustAppRatingCacheappBlockReview.create→dbWrite。 appCollaborator.findMany的负断言在两个客户端的服务源码中都不出现——作者未能裁决且明确没有猜要求先读用例再路由。不确定项诚实的边界声明文档单列了三个未决项这本身是分析文档质量的体现appCollaborator.findMany的来源——可能经由其他模块或另一个参数化客户端触达未解决是否有用例依赖两个客户端是同一对象这一事实——别名 mock 把两者合一某个测试可能在断言通过另一个客户端发出的调用而无人察觉同 slice 的model-appeal用例正是一层之下的这种形态一个刻意分离的事务客户端且这种依赖在 diff 中不可见resolveBlockInstance的 27 个用例并未逐个读过——只解析了入口与缺省值未解析每个用例的意图。如何验证一次拆分跑一遍套件是不够的文档最后给出的验证流程针对负断言静默通过这一根本风险先路由正断言并运行——正断言的错误路由是可见的对每个负断言把它翻转为正断言绑定到你认为代码使用的那个客户端并确认它该失败时确实失败。一个无法被制造出失败的断言不是被路由了而是惰性的residual-mocks.mjs门禁与收集计数在两种情况下都会干净——它们检测的是缺失absent不是空转vacuity。门禁能力的边界见 docs/testing/shared-module-mock-migration.md 的 What the gate does NOT catch 一节。小结两条可复用的工程经验这份分析的价值不在某个仓库的某六个文件而在两个可以泛化的结论参数化/运行时选择的依赖其使用事实归属于调用方而非被调方。静态工具grep、整模块扫描给出的 BOTH 或 零命中 都是问题而非答案裁决单位是测试实际导入的入口及其缺省分支——并且缺省分支必须逐站点核对因为同一文件里可能混着opts.db read ? dbRead : dbWrite与opts.db write ? dbWrite : dbRead两种语义相反的形式。负断言是 mock 拆分中风险最高的一类它在错误路由下平凡通过且门禁工具残留 mock 检查、计数校验只检测缺失不检测空转。翻转负断言确认其能失败是把惰性断言变回真实断言的最小成本手段。结合当前仓库状态看机制二的测试环境缺陷OC-317已由 src/tests/mocks/env.mock.ts 的 schema 派生默认值机制在结构性层面修复而机制一的调用点模式block-registry.service.ts中的四处opts.db read ? dbRead : dbWrite与一处反转形式在当前源码中依然存在——任何后续对这批桶做 mock 改造的工作仍应以本文的路由表与验证流程为基线。【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考