mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-06 22:38:23 +08:00
Apply the naming-conventions §6.1 acronym-casing rule (MCP -> Mcp) to the MCP* PascalCase identifier family across the codebase (McpServer, McpTool, McpToolResponse, BuiltinMcpServerNames, McpService, etc.) plus the local identifiers boundMcp/enableMcp/disableMcp and the didiMcp registry key. Regenerate the OpenAPI spec from the renamed schemas. Deliberately left unchanged (not naming-convention identifiers): persisted field keys read by migrators (enabledMCPs), v1 Redux selectors (selectMCP), string values (ExaMCP, logger labels), and UPPER_SNAKE constants (MCP_*). Also fix naming issues in the data reference docs that prompted this: - JSONStreamReader -> JsonStreamReader (match the real class name) - rowToMCPServer -> rowToMcpServer (match the real function name) - replace the TopicService getInstance() skeleton with a direct singleton - sync stale MCPServer/MCPTool/McpService references in affected docs
325 lines
20 KiB
Markdown
325 lines
20 KiB
Markdown
# Default Values & Nullability
|
||
|
||
Standards for placing default values across the data stack and judging column nullability.
|
||
Read this when designing a new SQLite table, defining a Zod entity schema, or reviewing PRs
|
||
that introduce defaults at any layer.
|
||
|
||
## Problem
|
||
|
||
A default value can technically be placed in **six** distinct locations in this stack:
|
||
|
||
| # | Layer | Timing | Direction |
|
||
|---|---|---|---|
|
||
| 1 | DB column `DEFAULT 'X'` | INSERT (SQL) | Write |
|
||
| 2 | Drizzle `$defaultFn` / `$default` | INSERT (JS, before SQL) | Write |
|
||
| 3 | Zod schema `.default()` (entity / Create / Update) | `.parse()` | Write |
|
||
| 4 | Service explicit `dto.x ?? DEFAULT` | Pre-INSERT | Write |
|
||
| 5 | `rowToEntity` `row.x ?? DEFAULT` | Post-SELECT | **Read** |
|
||
| 6 | Renderer form / hook pre-fill | Before POST | Write (upstream) |
|
||
|
||
When the same field has defaults defined in **more than one** place, the values must be kept
|
||
in sync by hand, and any drift produces silent bugs:
|
||
|
||
- **PATCH leakage**: Zod v4 `.partial()` retains `.default()` on inner fields, so a
|
||
`PATCH /entity/:id { fooId: 'x' }` body parsed against `UpdateSchema = CreateSchema.partial()`
|
||
materializes every default value. The service then writes those defaults to the row,
|
||
overwriting user-set fields. (Zod issues #4799, #5642, #4179.)
|
||
- **Read/write drift**: a `rowToEntity` masks DB NULL with a hardcoded `'🌟'`. Months later
|
||
someone changes the Zod create default to `'✨'`. New rows get `'✨'`; older rows still
|
||
surface as `'🌟'`. The two ends of the same field disagree.
|
||
|
||
The rules below close both classes of bug by enforcing **one source of truth per field**
|
||
plus **read paths that don't fabricate state the DB doesn't carry**.
|
||
|
||
## Five Rules
|
||
|
||
### R1. NULL vs NOT NULL must reflect domain semantics
|
||
|
||
A column is `nullable` only when **NULL carries a domain meaning distinct from any value
|
||
in the column's domain**. Examples:
|
||
|
||
- `assistant.modelId`: NULL = "no model selected yet" — a real product state, distinct from
|
||
any specific model id.
|
||
- `topic.deletedAt`: NULL = "not deleted" — no timestamp value can express this.
|
||
- `message.parentId`: NULL = "root node" — distinct from any non-empty id.
|
||
|
||
Otherwise the column is `NOT NULL`. If a column "should" always have a value but is
|
||
currently nullable, **fix the column constraint**, not the read path.
|
||
|
||
### R2. Each field has at most one source of truth for its default
|
||
|
||
Pick exactly one of locations #1–#4 (write-side) per field, plus location #5 only when
|
||
the field is genuinely `T | null` and the read should preserve it. **Never define the same
|
||
default in multiple places.** The chooser is in
|
||
[Decision Matrix 2](#decision-matrix-2-where-should-the-default-value-live).
|
||
|
||
### R3. Read path must not fabricate defaults
|
||
|
||
`rowToEntity` may only:
|
||
|
||
- spread a row;
|
||
- run `nullsToUndefined(row)` once at the SQLite NULL → TypeScript `undefined` boundary;
|
||
- call `timestampToISO` / `timestampToISOOrUndefined` for `Date.now()` ↔ ISO conversion;
|
||
- narrow string fields to literal-union types (e.g. `clean.type as McpServer['type']`).
|
||
|
||
`row.x ?? someValue` is **forbidden**. If the impulse arises, the column is mis-designed:
|
||
switch it to `NOT NULL` with a DB DEFAULT or `$defaultFn`, or accept that the entity field
|
||
is genuinely `T | null` and surface NULL to the renderer.
|
||
|
||
**Exception**: when the domain type declares `T | null` (e.g. `AssistantSchema.modelId.nullable()`),
|
||
bypass `clean` and reference `row.x` directly to preserve the contract. See
|
||
[Row → Entity Mapping](./data-api-in-main.md#row--entity-mapping).
|
||
|
||
### R4. Write path covers only what the database cannot
|
||
|
||
`service.create()` should pass values into `db.insert(...).values({...})` **only** for
|
||
columns where:
|
||
|
||
1. The column is `NOT NULL`, AND
|
||
2. The column has neither a DB `DEFAULT` nor a `$defaultFn`, AND
|
||
3. The DTO doesn't already supply a value.
|
||
|
||
For everything else, **omit the field**. Drizzle leaves it out of the SQL; the DB applies
|
||
its own default (or NULL for nullable columns). Don't restate values the DB already knows.
|
||
|
||
### R5. Update schema must derive from a defaults-free source
|
||
|
||
`UpdateSchema = CreateSchema.partial()` is **only safe when Create has no `.default()`
|
||
calls**. Because Zod v4 retains `.default()` through `.partial()`, deriving Update from a
|
||
Create that carries defaults causes PATCH bodies to materialize those defaults, which the
|
||
service then writes to the row.
|
||
|
||
When Create has defaults — or whenever in doubt — derive Update directly from the entity:
|
||
|
||
```ts
|
||
// ✅ Always safe
|
||
export const UpdateXxxSchema = XxxSchema.pick(XXX_MUTABLE_FIELDS).partial()
|
||
```
|
||
|
||
This dovetails with [API Design Guidelines § Rule C](./api-design-guidelines.md#c-derive-dtos-via-pick-whitelist-with-field-atoms-and-zstrictobject).
|
||
|
||
## Decision Matrix 1: Should this column be NULL or NOT NULL?
|
||
|
||
| Pattern | Choose | Example |
|
||
|---|---|---|
|
||
| Optional foreign key | `nullable` | `assistant.modelId`, `task.assigneeId`, `message.parentId` |
|
||
| Time of an event that may not have occurred | `nullable` | `deletedAt`, `cancelledAt`, `lastLoginAt` |
|
||
| Tri-state boolean | `nullable` | `verification.passed: true \| false \| null` |
|
||
| Sparse attribute where "absent" ≠ "empty" | `nullable` | `user.middleName`, `product.discontinuedReason` |
|
||
| Unassigned-tagged state | `nullable` | `pr.reviewerId` (unassigned vs assigned) |
|
||
| Every row should have a value, with `''` / `0` / `[]` as the "empty" form | `NOT NULL` + DB DEFAULT | `assistant.prompt = ''`, `agent.sortOrder = 0`, `tag.color` |
|
||
| Product policy "every row has X by default" | `NOT NULL` + DB DEFAULT | `assistant.emoji = '🌟'` |
|
||
| Counter / aggregate | `NOT NULL` + DB DEFAULT | `views`, `retryCount` |
|
||
| Audit timestamps | `NOT NULL` + `$defaultFn` | `createdAt`, `updatedAt` |
|
||
| Required foreign key | `NOT NULL` | `topic.userId`, `message.topicId` |
|
||
|
||
**Reverse check**: if `rowToEntity` carries `row.x ?? someValue` for column `x`, that is
|
||
reverse evidence that `x` should be `NOT NULL` — see R3.
|
||
|
||
## Decision Matrix 2: Where should the default value live?
|
||
|
||
| Location | Best for | Trade-off | SQLite-specific note |
|
||
|---|---|---|---|
|
||
| **#1 DB DEFAULT** (`text().notNull().default('')`) | Type-level "empty" values that **by definition won't change** (`''`, `0`, `false`, `[]`) | Single source at the schema; DB enforces it for any caller including raw SQL | **Effectively a near-permanent choice in SQLite** — every change forces a full-table rebuild and never touches existing rows. See [§ DB defaults are near-permanent](#db-defaults-are-near-permanent) below. |
|
||
| **#2 Drizzle `$defaultFn`** (`integer().$defaultFn(() => Date.now())`) | Dynamic per-row values: UUIDs, `Date.now()` | Lives in schema file but runs in the JS layer; consistent for all Drizzle-driven inserts | Doesn't apply to raw SQL writers — but those should be rare here |
|
||
| **#3 Zod `.default()`** | **Avoid** on entity / Create / Update — see warnings below | Couples shared schema package to runtime constants; forces `z.input` / `z.output` type split; bypasses non-handler callers (seeders, internal-service calls) | n/a |
|
||
| **#4 Service `dto.x ?? DEFAULT`** | Tunable product values that may evolve (e.g. `DEFAULT_ASSISTANT_SETTINGS`) | Lives next to business logic; covers **all** callers (handler, seeder, internal); changes are pure code edits with no migration | Best fit when the ideal value tracks product iteration |
|
||
|
||
### Why Zod `.default()` is discouraged
|
||
|
||
1. **Caller asymmetry** — Zod defaults run at `.parse()` time. Handler-driven inserts get
|
||
them; seeder / service-to-service / migration code paths construct DTOs directly and
|
||
don't, producing inconsistent rows.
|
||
2. **Type duality** — `.default()` makes a schema's `z.input` and `z.output` types diverge:
|
||
body callers see optional fields, service receivers see required ones. Either every
|
||
`Create*` schema spawns paired `…Body` / `…Dto` types, or one of the two ends is
|
||
mistyped.
|
||
3. **PATCH leakage** — see R5. Even when defaults live only on Create, deriving Update
|
||
from Create re-introduces them; deriving from entity adds rule complexity that's easy
|
||
to forget.
|
||
|
||
If a default truly must live in Zod (e.g., a query-string parameter with a baseline
|
||
value), keep it on the **specific** schema it applies to (typically `ListXxxQuerySchema`),
|
||
never on the entity, Create, or Update.
|
||
|
||
### DB defaults are near-permanent
|
||
|
||
Putting a value into a DB column `DEFAULT` for the first time costs nothing — it lands in the next migration's `CREATE TABLE`. **Changing it later is expensive and asymmetric**, so the first write is effectively the final one. Three forces compound:
|
||
|
||
- **SQLite has no `ALTER COLUMN SET DEFAULT`** ([sqlite.org/lang_altertable](https://www.sqlite.org/lang_altertable.html)). Changing a `DEFAULT` requires the 12-step table-rebuild dance: create a new table with the new schema, copy data, drop, rename, recreate indexes / triggers / FKs.
|
||
- **Each change costs a full-table rebuild at runtime**. `drizzle-kit` auto-generates the rebuild SQL (PRAGMA / `CREATE __new_xxx` / `INSERT ... SELECT` / `DROP` / `RENAME` / re-create indexes), so codegen is not the bottleneck — the SQLite operation is. It copies every row, holds a schema lock for the duration, and consumes ~2× temporary disk for the duplicated table; on multi-GB tables this is no longer free. FTS5 virtual tables and triggers attached to the rebuilt table are also dropped and must be recreated by separate custom-SQL statements.
|
||
- **`DEFAULT` changes never touch existing rows**. Rows created before the change keep their old default value. If the new constraint can't tolerate the old values (e.g. tightening to `NOT NULL` while legacy rows hold `NULL`), the rebuild's `INSERT ... SELECT` line must be hand-edited with `COALESCE(col, 'fallback')` — `drizzle-kit` will not synthesize that for you.
|
||
|
||
Before placing a value into a DB `DEFAULT`, ask:
|
||
|
||
| Question | If you can't confidently answer "yes" |
|
||
|---|---|
|
||
| Has this value been validated against real product usage? | Move to service `??` until validated |
|
||
| Is this value's meaning **stable** against provider updates / UX redesigns / A/B tests / regulatory changes? | Move to service `??` |
|
||
| Is "rows created before any future change keep the old default" acceptable? | Move to service `??`, or budget a backfill migration upfront |
|
||
|
||
**The safe bias**: only DB-DEFAULT the values that are **type-level "empty"** (`''`, `0`, `false`, `[]`) — those almost never change because they're absence markers, not product decisions. Anything that's a product choice (`'🌟'`, default model parameters, sentinel category values) goes in service `??` first; promote to DB only after the value has stabilized through at least one release cycle in production.
|
||
|
||
A service-side default change is a code edit, one PR, no migration risk. A DB `DEFAULT` change is a full-table rebuild migration: copy every row, recreate indexes / triggers / FTS, and hand-write `COALESCE` for any legacy NULL backfill the new constraint can't tolerate. Reviewed differently, gated differently, slow on production-sized tables. **Don't trade tomorrow's agility for today's tidiness.**
|
||
|
||
### Quick chooser
|
||
|
||
| Default value's character | Pick |
|
||
|---|---|
|
||
| Type-level "empty" by definition (`''`, `0`, `false`, `[]`) — won't change because not a product choice | DB DEFAULT |
|
||
| Dynamic per row (timestamps, UUIDs) | Drizzle `$defaultFn` |
|
||
| Product-chosen value (`'🌟'`, model parameters, sentinel category) — could conceivably evolve | Service `??` |
|
||
| Unsure whether it'll ever change | **Service `??`** — cheap to change later; promote to DB only after the value has stabilized |
|
||
|
||
Skip Zod regardless.
|
||
|
||
## Standard Layered Design
|
||
|
||
Reference end-state for an `assistant`-like entity, demonstrating R1–R5:
|
||
|
||
```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
|
||
})
|
||
```
|
||
|
||
```ts
|
||
// ─── 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>
|
||
```
|
||
|
||
```ts
|
||
// ─── Service ──────────────────────────────────────────────────
|
||
async create(dto: CreateAssistantDto): Promise<Assistant> {
|
||
const [row] = await 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()
|
||
return rowToAssistant(row)
|
||
}
|
||
|
||
async update(id: string, dto: UpdateAssistantDto): Promise<Assistant> {
|
||
const [row] = await this.db.update(assistantTable)
|
||
.set(dto) // Drizzle skips undefined — PATCH-correct
|
||
.where(eq(assistantTable.id, id)).returning()
|
||
return rowToAssistant(row)
|
||
}
|
||
```
|
||
|
||
```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)
|
||
}
|
||
}
|
||
```
|
||
|
||
## Anti-patterns
|
||
|
||
| Wrong | Why | Correct |
|
||
|---|---|---|
|
||
| Column nullable + `rowToEntity` does `row.x ?? someDefault` | Read path masks NULL state; future schema changes drift silently between layers | Make column `NOT NULL` with DB DEFAULT (R1, R3) |
|
||
| Same default value defined in DB DEFAULT, Zod `.default()`, and `rowToEntity` `??` | Three places must stay in sync; any change forgets one | Pick one source of truth (R2) |
|
||
| `UpdateSchema = CreateSchema.partial()` with `.default()` on Create fields | Zod v4 preserves defaults through `.partial()`; PATCH bodies materialize them and overwrite row state | Derive Update from entity directly (R5) |
|
||
| `.default(DEFAULT_X_SETTINGS)` on Zod entity / Create schema | Defaults bleed into every derived schema; non-handler callers bypass it; renderer typings split into z.input / z.output | Move default to service `??` (Decision Matrix 2) |
|
||
| `rowToEntity` running `?? '🌟'` to mask NULL | The product wants every row to have an icon — express it in the column constraint plus the **default-fill stage**, not the mapper | `text().notNull()` + service `dto.emoji ?? '🌟'` (product-chosen value belongs in service — see [§ DB defaults are near-permanent](#db-defaults-are-near-permanent)) |
|
||
| Service `create()` passes every field, including ones the DB has DEFAULTs for | Restates DB knowledge in app code; drift risk if defaults change in only one place | Omit fields the DB / `$defaultFn` already handles (R4) |
|
||
| Putting a product-chosen value (`'🌟'`, default `temperature`, sentinel category) in DB `DEFAULT` thinking "I can tune it later" | SQLite has no `ALTER COLUMN SET DEFAULT`; changing it requires a hand-written table-rebuild and doesn't update existing rows. The "tune later" assumption is false | Service `??`; promote to DB only after the value has stabilized through a release cycle (see [§ DB defaults are near-permanent](#db-defaults-are-near-permanent)) |
|
||
|
||
## Case Studies
|
||
|
||
### A. `assistant.prompt / emoji / description / settings` — anti-pattern (current state)
|
||
|
||
Three layers each define defaults:
|
||
|
||
| Field | DB column (`assistant.ts`) | Zod Create | rowToAssistant (`AssistantService.ts`) |
|
||
|---|---|---|---|
|
||
| `prompt` | `text().default('')` (nullable) | `.default('')` | `row.prompt ?? ''` |
|
||
| `emoji` | `text()` (nullable, **no** default) | `.default('🌟')` | `row.emoji ?? '🌟'` |
|
||
| `description` | `text().default('')` (nullable) | `.default('')` | `row.description ?? ''` |
|
||
| `settings` | `text({mode:'json'}).$type<AssistantSettings>()` (nullable) | `.default(DEFAULT_ASSISTANT_SETTINGS)` | `normalizeSettings(row.settings)` |
|
||
|
||
**Diagnosis**: violates R1 (columns "should" always have values but are nullable),
|
||
R2 (default in three places per field), R3 (`??` in rowMapper).
|
||
|
||
**Fix**: per the Standard Layered Design above. After the fix `prompt` / `description`
|
||
move to DB DEFAULT (type-level empty); `emoji` and `settings` move to service `??`
|
||
(product-chosen / tunable values that may evolve, per [§ DB defaults are near-permanent](#db-defaults-are-near-permanent));
|
||
`rowToAssistant` no longer fabricates anything.
|
||
|
||
### B. `assistant.modelId` — correct (current state)
|
||
|
||
The DB column `text().references(...)` is nullable; the entity declares
|
||
`UniqueModelIdSchema.nullable()`; the row mapper reads `row.modelId` directly to preserve
|
||
the `T | null` contract; the renderer treats NULL as "no model selected" and renders
|
||
accordingly.
|
||
|
||
**Diagnosis**: this is what a legitimately-nullable field looks like — NULL has a domain
|
||
meaning, no read-path mask, no fabricated default.
|
||
|
||
### C. `agent.accessiblePaths` — anti-pattern (current state)
|
||
|
||
DB column (`agent.ts`): `text({ mode: 'json' }).$type<string[]>()` — nullable, no DB
|
||
DEFAULT. RowMapper (`AgentService.ts` `rowToAgent`): `accessiblePaths: row.accessiblePaths ?? []`.
|
||
|
||
**Diagnosis**: same shape as Case A. The product wants every agent to have a non-empty
|
||
workspace path list (`AgentService.computeWorkspacePaths` even enforces this on create),
|
||
so the column should be `NOT NULL` and the rowMapper's `?? []` should disappear.
|
||
|
||
## Related References
|
||
|
||
- [API Design Guidelines § Rule C](./api-design-guidelines.md#c-derive-dtos-via-pick-whitelist-with-field-atoms-and-zstrictobject) — DTO derivation rules
|
||
- [Database Patterns § Column Nullability and Defaults](./database-patterns.md#column-nullability-and-defaults) — column-level decision
|
||
- [DataApi in Main § Row → Entity Mapping](./data-api-in-main.md#row--entity-mapping) — `nullsToUndefined`, `T | null` preservation
|
||
- [Zod issue #4799](https://github.com/colinhacks/zod/issues/4799) — `.partial()` and `.default()` interaction
|
||
- [SQLite ALTER TABLE limitations](https://www.sqlite.org/lang_altertable.html) — why DB DEFAULT changes are painful
|
||
- [drizzle-team/drizzle-orm#2489](https://github.com/drizzle-team/drizzle-orm/issues/2489) — drizzle-kit's unsupported-SQLite-ALTER comment doesn't name the affected table/column
|