Cherry Studio 数据层默认值与可空性规范:从 DB DEFAULT 到 Zod 的单一事实来源实践指南
2026/9/13 10:27:05 网站建设 项目流程

Cherry Studio 数据层默认值与可空性规范:从 DB DEFAULT 到 Zod 的单一事实来源实践指南

【免费下载链接】cherry-studioAI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs项目地址: https://gitcode.com/GitHub_Trending/ch/cherry-studio

导读

本文是 Cherry Studio 数据访问层(Data API)的工程规范指南,围绕"默认值该放在哪里、列该不该可空"这两个贯穿 SQLite 表设计、Drizzle Schema、Zod 实体校验与 Service 业务逻辑的核心问题,给出五条硬性规则、两张决策矩阵与一套可落地的分层设计范本。读完本文,你将掌握如何在六层技术栈中为每个字段确立"单一事实来源",避免 PATCH 泄漏(PATCH leakage)与读写漂移(read/write drift)两类隐蔽 bug,并理解为何 SQLite 的 DB DEFAULT 一旦写入就"近乎永久"、产品型默认值必须优先放在 Service 层。

本文以 docs/references/data/best-practice-default-values-and-nullability.md 为骨架,并结合 Cherry Studio 仓库中assistant实体的真实实现展开佐证。

问题:默认值在六层技术栈中的六个落点

一个默认值在 Cherry Studio 的数据栈中,技术上有六个不同的放置位置,从写入端一直延伸到读取端:

#时机方向
1DB 列DEFAULT 'X'INSERT(SQL 层)
2Drizzle$defaultFn/$defaultINSERT(JS 层,SQL 之前)
3Zod schema.default()(entity / Create / Update).parse()
4Service 显式dto.x ?? DEFAULTINSERT 之前
5rowToEntity中的row.x ?? DEFAULTSELECT 之后
6Renderer 表单 / hook 预填POST 之前写(上游)

当同一个字段在不止一个位置定义了默认值,这些值就必须靠人手去保持同步,任何一处漂移都会产生静默 bug,典型的有两类:

  • PATCH 泄漏(PATCH leakage):Zod v4 的.partial()保留内层字段的.default()。于是PATCH /entity/:id { fooId: 'x' }这种请求体一旦用UpdateSchema = CreateSchema.partial()解析,就会把 Create 里所有默认值全部物化出来,Service 随后把这些默认值写进行,覆盖用户真正设置的字段。(对应 Zod issue #4799、#5642、#4179 描述的.partial().default()交互行为。)
  • 读写漂移(read/write drift)rowToEntity用硬编码的'🌟'掩盖 DB 的 NULL,几个月后有人把 Zod 的 Create 默认值改成了'✨'。于是新行得到'✨',旧行仍然显示'🌟'—— 同一个字段的两端对不上。

下文五条规则正是为关闭这两类 bug 而设:核心是每个字段只有一个事实来源,以及读路径绝不凭空捏造数据库并不存在的状态

五条规则(Five Rules)

R1:NULL 与 NOT NULL 必须反映领域语义

一列只有在NULL 携带与列值域中任何值都不同的领域含义时,才允许nullable。例如:

  • assistant.modelId:NULL = "尚未选择模型" —— 一个真实的产品状态,与任何具体 model id 都不同。仓库中 assistant.ts 表定义 正是modelId: text().references(() => userModelTable.id, { onDelete: 'set null' }),注释明确写着 "Legitimately nullable (R1): NULL = 'no model selected yet'"。
  • topic.deletedAt:NULL = "未删除" —— 没有任何时间戳值能表达这个状态。_columnHelpers.ts的时间戳语义注释 明确指出deletedAt刻意保持 nullable:NULL 编码 "未软删除",写入时间戳则标记为软删除。
  • message.parentId:NULL = "根节点" —— 与任何非空 id 都不同。真实实现见 message.ts,并且仓库还用check('message_root_parent_check', ...)约束把 "role = 'root' ⇔ parentId IS NULL" 固化成了 DB 不变量。

除此之外的列一律NOT NULL。如果某列"按理总该有值"但现在却是可空的,应该修复列约束本身,而不是修复读路径

R2:每个字段的默认值至多有一个事实来源

在写入侧的 #1–#4 中为每个字段恰好选择一个位置;#5 仅在字段确实是T | null且读取时需要保留 NULL 时才使用。绝不在多个位置定义同一个默认值。具体选哪个,见下文 决策矩阵 2。

R3:读路径不得捏造默认值

rowToEntity只允许做四件事:

  1. 展开(spread)一行;
  2. 在 SQLite NULL → TypeScriptundefined的边界上执行一次nullsToUndefined(row)
  3. timestampToISO/timestampToISOOrUndefinedDate.now()↔ ISO 字符串转换;
  4. 把字符串字段收窄为字面量联合类型(如clean.type as McpServer['type'])。

row.x ?? someValue禁止的。如果产生这个念头,说明列设计错了:要么改成NOT NULL配 DB DEFAULT 或$defaultFn,要么承认该实体字段确实是T | null、把 NULL 直接暴露给渲染层。

例外:当领域类型声明为T | null(例如AssistantSchema.modelId.nullable())时,绕过clean、直接引用row.x以保留契约。参见 DataApi in Main § Row → Entity Mapping。

仓库中的nullsToUndefined实现位于 rowMappers.ts,它是有意做成**浅层(shallow)**的:只把顶层null换成undefined,不触碰嵌套 JSON 载荷;类型层面,包含null的字段被收窄为Exclude<T, null> | undefined,而notNull()列则原样穿透。timestampToISO(同文件 L42-L44)在签名上就拒绝null | undefined,因为new Date(null)会静默得到 Unix 纪元"1970-01-01T00:00:00.000Z"—— 这正是 R3 要消灭的静默失败模式。

R4:写路径只覆盖数据库管不了的字段

service.create()db.insert(...).values({...})传值,允许针对满足以下全部三个条件的列:

  1. 该列是NOT NULL,且
  2. 该列既没有 DBDEFAULT也没有$defaultFn,且
  3. DTO 没有提供该值。

其余字段一律省略。Drizzle 会把它排除在 SQL 之外,数据库自行应用默认值(可空列则落 NULL)。不要复述数据库已知的值。

R5:更新 Schema 必须从"无默认值"的源派生

UpdateSchema = CreateSchema.partial()仅在 Create 没有任何.default()调用时才安全。因为 Zod v4 会让.default()穿透.partial(),从带默认值的 Create 派生 Update,会导致 PATCH 请求体物化这些默认值,Service 再把它写进行。

当 Create 带默认值 —— 或者拿不准 —— 时,直接从实体派生 Update:

// ✅ Always safe export const UpdateXxxSchema = XxxSchema.pick(XXX_MUTABLE_FIELDS).partial()

这与 API Design Guidelines § Rule C(DTO 通过 pick 白名单 + 字段原子 + z.strictObject 派生) 相互呼应。仓库中 assistants.ts 的 API Schema 就是按此实现的:UpdateAssistantSchema直接AssistantSchema.pick(ASSISTANT_MUTABLE_FIELDS).partial(),并在注释中写明 "Update picks directly from the entity, not Create, so Create defaults do not bleed into partial updates"。

决策矩阵 1:这一列该 NULL 还是 NOT NULL?

模式选择示例
可选外键nullableassistant.modelIdtask.assigneeIdmessage.parentId
可能尚未发生的事件时间nullabledeletedAtcancelledAtlastLoginAt
三态布尔nullableverification.passed: true \| false \| null
"缺失 ≠ 为空"的稀疏属性nullableuser.middleNameproduct.discontinuedReason
未指派标记状态nullablepr.reviewerId(未指派 vs 已指派)
每行都应有值,以''/0/[]作为"空"形态NOT NULL+ DB DEFAULTassistant.prompt = ''agent.sortOrder = 0tag.color
产品策略"每行默认都有 X"NOT NULL+ DB DEFAULTassistant.emoji = '🌟'
计数器 / 聚合值NOT NULL+ DB DEFAULTviewsretryCount
审计时间戳NOT NULL+$defaultFncreatedAtupdatedAt
必填外键NOT NULLtopic.userIdmessage.topicId

反向检查:如果rowToEntity对列x写了row.x ?? someValue,那就是x应当改为NOT NULL的反向证据 —— 参见 R3。

配套的 Database Patterns § Column Nullability and Defaults 还补充了一个高频反面案例:布尔列忘记.notNull()

// ❌ Wrong — inferred type is `boolean | null` isEnabled: integer({ mode: 'boolean' }).default(true) // ✅ Right isEnabled: integer({ mode: 'boolean' }).notNull().default(true)

mode: 'boolean'给读者暗示只有两个值,但 Drizzle 把可空性与默认值视为正交概念。不加.notNull(),每个读者就都得写row.isEnabled ?? true—— 这正是 R3 禁止的捏造回退模式。.default(true)只在 INSERT 时生效,并不能约束已存在的 NULL。

决策矩阵 2:默认值应该放在哪里?

位置最适合权衡SQLite 专属注意点
#1 DB DEFAULTtext().notNull().default('')类型级"空"值,定义上就不会变''0false[]Schema 内单一来源;数据库对任何调用方(包括裸 SQL)强制生效在 SQLite 中近乎永久的选择—— 每次改动都强制全表重建,且永远不会触及已存在的行。见下文 DB 默认值近乎永久。
#2 Drizzle$defaultFninteger().$defaultFn(() => Date.now())每行动态值:UUID、Date.now()位于 schema 文件但在 JS 层执行;对所有 Drizzle 驱动的插入保持一致对裸 SQL 写入者不生效 —— 但这类场景在仓库中应属罕见
#3 Zod.default()实体 / Create / Update 上避免—— 见下方警告把共享 schema 包与运行时常量耦合;强制z.input/z.output类型分裂;绕过非 handler 调用方(seeder、内部 service 调用)不适用
#4 Servicedto.x ?? DEFAULT可调优、可能演进的产品值(如DEFAULT_ASSISTANT_SETTINGS紧邻业务逻辑;覆盖所有调用方(handler、seeder、内部 service);改动只是纯代码编辑、无需迁移最契合"理想值跟随产品迭代"的场景

为什么 Zod.default()被劝阻

  1. 调用方不对称(caller asymmetry)—— Zod 默认值在.parse()时生效。handler 驱动的插入能得到它;但 seeder、service 间调用、迁移代码路径是直接构造 DTO 的,得不到它,于是产生不一致的行。
  2. 类型二重性(type duality)——.default()让 schema 的z.inputz.output类型分叉:请求体调用方看到的是可选字段,Service 接收方看到的却是必填字段。要么每个Create*schema 都派生一对…Body/…Dto类型,要么其中一端必然被错误地类型标注。
  3. PATCH 泄漏—— 见 R5。即使默认值只存在于 Create 上,从 Create 派生 Update 仍会把它们带回来;从实体派生则增加容易遗忘的规则复杂度。

如果默认值真的必须放在 Zod 里(例如带基线值的查询字符串参数),那就放在它专属的schema 上(典型如ListXxxQuerySchema),绝不放实体、Create 或 Update 上。仓库中 ListAssistantsQuerySchema 就是被允许的特例:pagelimit使用.default(ASSISTANTS_DEFAULT_PAGE)/.default(ASSISTANTS_DEFAULT_LIMIT)(常量定义在 同文件 L98-L100),并配套z.input(渲染层查询参数,可选)与z.output(Service 层查询,默认值已保证填齐)的类型分裂。

DB 默认值近乎永久

把值第一次写进 DB 列DEFAULT不花什么成本 —— 它落在下一个迁移的CREATE TABLE里。之后想改它却昂贵且不对称,所以第一次写入事实上就是最终形态。三重因素叠加:

  • SQLite 没有ALTER COLUMN SET DEFAULT。修改DEFAULT需要走经典的 12 步全表重建流程:按新 schema 建新表、拷贝数据、删旧表、改名、重建索引 / 触发器 / 外键。
  • 每次改动都要在运行时做一次全表重建drizzle-kit会自动生成重建 SQL(PRAGMA /CREATE __new_xxx/INSERT ... SELECT/DROP/RENAME/ 重建索引),所以代码生成不是瓶颈 —— SQLite 操作本身才是。它要复制每一行、在重建期间持有 schema 锁、为复制的表消耗约 2 倍的临时磁盘空间;在多 GB 的表上,这不再是免费操作。附着在重建表上的 FTS5 虚拟表和触发器也会被丢弃,必须由额外的自定义 SQL 语句重新创建。
  • DEFAULT变更永远不会触及已存在的行。变更之前创建的行保留旧默认值。如果新约束无法容忍旧值(例如收紧为NOT NULL而历史行还持有 NULL),重建的INSERT ... SELECT就必须手写COALESCE(col, 'fallback')——drizzle-kit不会替你合成这段逻辑。

在把值放进 DBDEFAULT之前,先问三个问题:

问题如果不能自信地回答"是"
该值是否已对照真实产品使用场景验证过?移到 Service??,验证通过再说
该值的含义对 provider 更新 / UX 改版 / A/B 测试 / 监管变化是否稳定移到 Service??
"未来任何变更之前创建的行保持旧默认值"是否可以接受?移到 Service??,或事先为 backfill 迁移预留预算

安全倾向:只有类型级"空"值''0false[])才放入 DB DEFAULT —— 它们几乎不会变,因为它们是"缺席标记"而非产品决策。任何属于产品选择的值('🌟'、默认模型参数、哨兵分类值)先放进 Service??;只有该值在线上稳定运行至少一个发布周期后,才考虑提升到 DB。

Service 侧默认值的一次改动 = 一次代码编辑、一个 PR、零迁移风险。DBDEFAULT的一次改动 = 一次全表重建迁移:复制每一行、重建索引 / 触发器 / FTS、为任何新约束无法容忍的历史 NULL 手写COALESCE回填。审查门槛不同、发布门槛不同、在生产级大表上缓慢。不要用明天的灵活性换取今天的整洁。

快速选择器

默认值的性质选择
定义上的类型级"空"(''0false[])—— 不是产品选择,所以不会变DB DEFAULT
每行动态值(时间戳、UUID)Drizzle$defaultFn
产品选定的值('🌟'、模型参数、哨兵分类值)—— 可能演进Service??
拿不准会不会变Service??—— 以后改起来便宜;值稳定后再提升到 DB

无论哪种情况,跳过 Zod

标准分层设计:assistant 实体范本

这是assistant一类实体的参考终态,完整演示 R1–R5 如何落到四层代码上。仓库中的真实实现与文档范本一一对应,可以直接对照阅读。

① DB Schema 层(src/main/data/db/schemas/assistant.ts)

// ─── DB schema ──────────────────────────────────────────────── // Stable defaults live here; settings has no DB DEFAULT because it's a // tunable product value (Service is its source of truth). export const assistantTable = sqliteTable('assistant', { id: uuidPrimaryKey(), // $defaultFn UUID name: text().notNull(), // required, no default prompt: text().notNull().default(''), // type-level empty: DB handles emoji: text().notNull(), // product-chosen ('🌟' may evolve): Service fills description: text().notNull().default(''), // type-level empty: DB handles modelId: text().references(() => userModelTable.id), // legitimately nullable (R1) settings: text({ mode: 'json' }) .$type<AssistantSettings>() .notNull(), // NOT NULL, no DB DEFAULT — Service fills ...createUpdateDeleteTimestamps // $defaultFn for createdAt / updatedAt })

仓库中的真实定义还额外包含groupId(可空外键)、orderKey(分数索引排序键,由orderKeyColumns注入,见 _columnHelpers.ts),并建立了assistant_created_at_idx索引。其中:

  • uuidPrimaryKey()实际是text().primaryKey().$defaultFn(() => uuidv4())—— 对应 #2 层的$defaultFnUUID;
  • createUpdateDeleteTimestampscreatedAt/updatedAtinteger().notNull().$defaultFn(createTimestamp)updatedAt还带$onUpdateFn),deletedAt保持integer()可空 —— 审计时间戳走$defaultFn、软删除时间戳走语义化 NULL,两条规则在真实 schema 中同时成立(_columnHelpers.ts L39-L52)。

② Zod Schema 层(src/shared/data/types/assistant.ts + assistants.ts API Schema)

// ─── Zod schema ─────────────────────────────────────────────── // Pure shape: no .default() calls anywhere. export const AssistantSchema = z.strictObject({ id: AssistantIdSchema, name: z.string().min(1), prompt: z.string(), emoji: z.emoji(), description: z.string(), modelId: UniqueModelIdSchema.nullable(), // T | null contract preserved settings: AssistantSettingsSchema, createdAt: z.iso.datetime(), updatedAt: z.iso.datetime() }) export type Assistant = z.infer<typeof AssistantSchema> const ASSISTANT_MUTABLE_FIELDS = { name: true, prompt: true, emoji: true, description: true, modelId: true, settings: true } as const // Create: all mutable fields, all optional except `name`. No defaults. export const CreateAssistantSchema = AssistantSchema .pick(ASSISTANT_MUTABLE_FIELDS).partial().required({ name: true }) export type CreateAssistantDto = z.infer<typeof CreateAssistantSchema> // Update: derived from entity, not from Create. R5. export const UpdateAssistantSchema = AssistantSchema .pick(ASSISTANT_MUTABLE_FIELDS).partial() export type UpdateAssistantDto = z.infer<typeof UpdateAssistantSchema>

真实仓库中的ASSISTANT_MUTABLE_FIELDS白名单还包含groupIdmcpServerIdsknowledgeBaseIds(关联表同步字段),并明确把modelName(读时经 ModelService 解析)、orderKey(Service 所有,走/assistants/:id/order)排除在可编辑范围之外 —— 任何白名单之外的字段都会在 API 边界被默认拒绝(assistants.ts L30-L40)。另外UpdateAssistantSchemasettings做了深度 partial 扩展:客户端可以只改一个推理参数而不必重发其余参数,Service 层会把 partial 合并到已持久化的 settings 对象上再写回。

③ Service 层(src/main/data/services/AssistantService.ts)

// ─── Service ────────────────────────────────────────────────── create(dto: CreateAssistantDto): Assistant { const row = this.db.insert(assistantTable).values({ ...dto, emoji: dto.emoji ?? '🌟', // product-chosen default: Service is the source of truth settings: dto.settings ?? DEFAULT_ASSISTANT_SETTINGS // tunable product default: Service is the source of truth // prompt / description omitted → DB DEFAULT '' applies // modelId omitted (or null) → SQLite stores NULL }).returning().get() return rowToAssistant(row) } update(id: string, dto: UpdateAssistantDto): Assistant { const row = this.db.update(assistantTable) .set(dto) // Drizzle skips undefined — PATCH-correct .where(eq(assistantTable.id, id)).returning().get() return rowToAssistant(row) }

真实实现(AssistantService.ts L362-L389)在createTx中做了更多工作:把mcpServerIds/knowledgeBaseIds关联字段从列字段中拆分出来;modelIdresolveCreateModelId解析 —— 显式值严格校验(不存在则抛 validation 错误),省略时回退到chat.default_model_id偏好(过期则logger.warn并返回 null);orderKeyinsertWithOrderKey基于现有最大值计算下一个分数键再注入。而emoji: dto.emoji ?? '🌟'settings: dto.settings ?? DEFAULT_ASSISTANT_SETTINGS正是决策矩阵 #4 的真实落点,其中DEFAULT_ASSISTANT_SETTINGS定义在 src/shared/data/types/assistant.ts,集中了 temperature、topP、maxTokens、mcpMode、maxToolCalls 等一组可演进的产品默认值。

update(AssistantService.ts L495-L570)则演示了 PATCH 语义:先剔除undefined字段再setsettings走"当前值 + partial 合并"而不是整体覆盖,并带有软删除 TOCTOU 守卫(事务内所有写入都以isNull(deletedAt)为门槛)。

④ Row → Entity 层(src/main/data/services/utils/rowMappers.ts)

// ─── Row → Entity ───────────────────────────────────────────── // No `??` fallbacks. R3. function rowToAssistant(row: typeof assistantTable.$inferSelect): Assistant { const clean = nullsToUndefined(row) return { ...clean, modelId: row.modelId, // preserve T | null contract createdAt: timestampToISO(row.createdAt), updatedAt: timestampToISO(row.updatedAt) } }

真实实现(AssistantService.ts L56-L73)与此完全一致:const clean = nullsToUndefined(row)之后展开,modelId: row.modelId as UniqueModelId | nullgroupId: row.groupId直接引用原始行以保留T | null契约(R3 例外),时间戳走timestampToISO

反模式对照表

错误写法为什么错正确做法
列可空 +rowToEntityrow.x ?? someDefault读路径掩盖 NULL 状态;未来 schema 变更在层与层之间静默漂移改成NOT NULL配 DB DEFAULT(R1、R3)
同一默认值在 DB DEFAULT、Zod.default()rowToEntity??三处都定义三处必须手动同步;任何一次改动都会漏掉其中一处只选一个事实来源(R2)
Create 字段带.default()却用UpdateSchema = CreateSchema.partial()Zod v4 让默认值穿透.partial();PATCH 请求体物化默认值并覆盖行状态直接从实体派生 Update(R5)
在 Zod entity / Create schema 上.default(DEFAULT_X_SETTINGS)默认值渗入所有派生 schema;非 handler 调用方绕过它;渲染层类型分裂为 z.input / z.output把默认值移到 Service??(决策矩阵 2)
rowToEntity?? '🌟'掩盖 NULL产品希望每行都有图标 —— 应该表达在列约束加默认填充阶段,而不是 mapper 里text().notNull()+ Servicedto.emoji ?? '🌟'(产品选择值属于 Service,见 DB 默认值近乎永久)
Servicecreate()把每个字段都传进去,包括数据库已有 DEFAULT 的字段在应用代码里复述数据库知识;只有一处改动默认值就会漂移省略 DB /$defaultFn已处理的字段(R4)
把产品选择值('🌟'、默认 temperature、哨兵分类值)放进 DBDEFAULT,心想"以后还能调"SQLite 没有ALTER COLUMN SET DEFAULT;改动需要手写全表重建,且不会更新已有行。"以后还能调"的假设是假的Service??;值稳定运行一个发布周期后再提升到 DB(见 DB 默认值近乎永久)

仓库中的当前实例

assistant.promptassistant.description:类型级空值

两列都是NOT NULL DEFAULT ''。Create DTO 省略这两个字段,直接委托数据库默认值;rowToAssistant读到的就是字符串,无任何回退。空值是结构性的、稳定的,所以归数据库所有。对照 assistant.ts 的注释 "Type-level empty: DB DEFAULT is the single source of truth"。

assistant.emojiassistant.settings:产品默认值

两列都是NOT NULL,但不带数据库默认值。AssistantService在 create 时提供'🌟'DEFAULT_ASSISTANT_SETTINGS;行 mapper 直接读取存储值。这些产品选择可以在不重建表、不修改默认子句的情况下演进。对照 assistant.ts 与 AssistantService.ts L377-L378。

assistant.modelId:有意义的 NULL

这个可空外键表示"未选择模型"。实体 schema 保留null,行 mapper 直接读row.modelId。没有任何层用捏造的默认值替换这个领域状态。对照 assistant.ts L26-L28 与 AssistantService.ts L64-L65 的 "R3 exception" 注释。

这三列合起来恰好覆盖了规范支持的三种情形:稳定的类型级 DB 默认值、写入NOT NULL列的 Service 持有产品默认值、以及真正可空的领域字段。

关联参考资料

  • API Design Guidelines § Rule C(DTO 派生规则)
  • Database Patterns § Column Nullability and Defaults(列级决策)
  • DataApi in Main § Row → Entity Mapping(nullsToUndefinedT | null保留)
  • 相关外部资料线索(供进一步检索,非仓库证据):Zod.partial().default()交互问题(issue #4799);SQLite ALTER TABLE 能力限制(sqlite.org 的 lang_altertable 文档);drizzle-kit 对 SQLite 不支持的 ALTER 仅给出未指明表/列名的注释(drizzle-team/drizzle-orm#2489)。

【免费下载链接】cherry-studioAI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs项目地址: https://gitcode.com/GitHub_Trending/ch/cherry-studio

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询