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 的数据栈中,技术上有六个不同的放置位置,从写入端一直延伸到读取端:
| # | 层 | 时机 | 方向 |
|---|---|---|---|
| 1 | DB 列DEFAULT 'X' | INSERT(SQL 层) | 写 |
| 2 | Drizzle$defaultFn/$default | INSERT(JS 层,SQL 之前) | 写 |
| 3 | Zod schema.default()(entity / Create / Update) | .parse()时 | 写 |
| 4 | Service 显式dto.x ?? DEFAULT | INSERT 之前 | 写 |
| 5 | rowToEntity中的row.x ?? DEFAULT | SELECT 之后 | 读 |
| 6 | Renderer 表单 / 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只允许做四件事:
- 展开(spread)一行;
- 在 SQLite NULL → TypeScript
undefined的边界上执行一次nullsToUndefined(row); - 用
timestampToISO/timestampToISOOrUndefined做Date.now()↔ ISO 字符串转换; - 把字符串字段收窄为字面量联合类型(如
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({...})传值,只允许针对满足以下全部三个条件的列:
- 该列是
NOT NULL,且 - 该列既没有 DB
DEFAULT也没有$defaultFn,且 - 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?
| 模式 | 选择 | 示例 |
|---|---|---|
| 可选外键 | nullable | assistant.modelId、task.assigneeId、message.parentId |
| 可能尚未发生的事件时间 | nullable | deletedAt、cancelledAt、lastLoginAt |
| 三态布尔 | nullable | verification.passed: true \| false \| null |
| "缺失 ≠ 为空"的稀疏属性 | nullable | user.middleName、product.discontinuedReason |
| 未指派标记状态 | nullable | pr.reviewerId(未指派 vs 已指派) |
每行都应有值,以''/0/[]作为"空"形态 | NOT NULL+ DB DEFAULT | assistant.prompt = ''、agent.sortOrder = 0、tag.color |
| 产品策略"每行默认都有 X" | NOT NULL+ DB DEFAULT | assistant.emoji = '🌟' |
| 计数器 / 聚合值 | NOT NULL+ DB DEFAULT | views、retryCount |
| 审计时间戳 | NOT NULL+$defaultFn | createdAt、updatedAt |
| 必填外键 | NOT NULL | topic.userId、message.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 DEFAULT(text().notNull().default('')) | 类型级"空"值,定义上就不会变(''、0、false、[]) | Schema 内单一来源;数据库对任何调用方(包括裸 SQL)强制生效 | 在 SQLite 中近乎永久的选择—— 每次改动都强制全表重建,且永远不会触及已存在的行。见下文 DB 默认值近乎永久。 |
#2 Drizzle$defaultFn(integer().$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()被劝阻
- 调用方不对称(caller asymmetry)—— Zod 默认值在
.parse()时生效。handler 驱动的插入能得到它;但 seeder、service 间调用、迁移代码路径是直接构造 DTO 的,得不到它,于是产生不一致的行。 - 类型二重性(type duality)——
.default()让 schema 的z.input和z.output类型分叉:请求体调用方看到的是可选字段,Service 接收方看到的却是必填字段。要么每个Create*schema 都派生一对…Body/…Dto类型,要么其中一端必然被错误地类型标注。 - PATCH 泄漏—— 见 R5。即使默认值只存在于 Create 上,从 Create 派生 Update 仍会把它们带回来;从实体派生则增加容易遗忘的规则复杂度。
如果默认值真的必须放在 Zod 里(例如带基线值的查询字符串参数),那就放在它专属的schema 上(典型如ListXxxQuerySchema),绝不放实体、Create 或 Update 上。仓库中 ListAssistantsQuerySchema 就是被允许的特例:page和limit使用.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 迁移预留预算 |
安全倾向:只有类型级"空"值(''、0、false、[])才放入 DB DEFAULT —— 它们几乎不会变,因为它们是"缺席标记"而非产品决策。任何属于产品选择的值('🌟'、默认模型参数、哨兵分类值)先放进 Service??;只有该值在线上稳定运行至少一个发布周期后,才考虑提升到 DB。
Service 侧默认值的一次改动 = 一次代码编辑、一个 PR、零迁移风险。DBDEFAULT的一次改动 = 一次全表重建迁移:复制每一行、重建索引 / 触发器 / FTS、为任何新约束无法容忍的历史 NULL 手写COALESCE回填。审查门槛不同、发布门槛不同、在生产级大表上缓慢。不要用明天的灵活性换取今天的整洁。
快速选择器
| 默认值的性质 | 选择 |
|---|---|
定义上的类型级"空"(''、0、false、[])—— 不是产品选择,所以不会变 | 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;createUpdateDeleteTimestamps中createdAt/updatedAt是integer().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白名单还包含groupId、mcpServerIds、knowledgeBaseIds(关联表同步字段),并明确把modelName(读时经 ModelService 解析)、orderKey(Service 所有,走/assistants/:id/order)排除在可编辑范围之外 —— 任何白名单之外的字段都会在 API 边界被默认拒绝(assistants.ts L30-L40)。另外UpdateAssistantSchema对settings做了深度 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关联字段从列字段中拆分出来;modelId经resolveCreateModelId解析 —— 显式值严格校验(不存在则抛 validation 错误),省略时回退到chat.default_model_id偏好(过期则logger.warn并返回 null);orderKey由insertWithOrderKey基于现有最大值计算下一个分数键再注入。而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字段再set,settings走"当前值 + 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 | null与groupId: row.groupId直接引用原始行以保留T | null契约(R3 例外),时间戳走timestampToISO。
反模式对照表
| 错误写法 | 为什么错 | 正确做法 |
|---|---|---|
列可空 +rowToEntity里row.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.prompt与assistant.description:类型级空值
两列都是NOT NULL DEFAULT ''。Create DTO 省略这两个字段,直接委托数据库默认值;rowToAssistant读到的就是字符串,无任何回退。空值是结构性的、稳定的,所以归数据库所有。对照 assistant.ts 的注释 "Type-level empty: DB DEFAULT is the single source of truth"。
assistant.emoji与assistant.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(
nullsToUndefined与T | 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),仅供参考