Files
CherryHQ-cherry-studio/docs/references/data/api-design-guidelines.md
SuYao 611944599f refactor(deps): replace lodash with es-toolkit/compat and drop it (#16528)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: suyao <sy20010504@gmail.com>
2026-06-29 12:28:57 +08:00

641 lines
29 KiB
Markdown

# API Design Guidelines
Guidelines for designing RESTful APIs in the Cherry Studio Data API system.
> **File organization is separate from path design.** For which `schemas/*.ts` file a route belongs in, see [Schema File Organization](./api-types.md#schema-file-organization). This guide covers the *shape* of paths only.
## Path Naming
| Rule | Example | Notes |
|------|---------|-------|
| Use plural nouns for collections | `/topics`, `/messages` | Resources are collections |
| Use kebab-case for multi-word paths | `/user-settings` | Not camelCase or snake_case |
| Express hierarchy via nesting | `/topics/:topicId/messages` | Parent-child relationships |
| Avoid verbs for CRUD operations | `/topics` not `/getTopics` | HTTP methods express action |
## Resource ↔ Table Naming
When a route is backed by a SQLite table, the route, table, and type names MUST express one shared domain noun, each in its own layer's casing:
| Layer | Convention | Example |
|---|---|---|
| DB table | singular snake_case | `agent_session` |
| REST route (collection) | plural kebab-case | `/agent-sessions` |
| Schema / entity type | singular PascalCase | `AgentSessionEntity` |
| Inferred row type | `XxxRow` ([§5.3](../naming-conventions.md#53-drizzle-schema-inferred-row-types)) | `AgentSessionRow` |
A route noun that diverges from its backing table's concept is drift — fix the route, not the table.
**Exceptions** (noun diverges from a single table):
| Case | Example |
|---|---|
| Shared / library resource | `/skills`, `/models` |
| Nested sub-resource | `/agents/:agentId/tasks` |
| Aggregate / derived / non-CRUD | `/topics/search`, `/topics/stats` |
## HTTP Method Semantics
| Method | Purpose | Idempotent | Typical Response |
|--------|---------|------------|------------------|
| GET | Retrieve resource(s) | Yes | 200 + data |
| POST | Create resource | No | 201 + created entity |
| PUT | Replace entire resource | Yes | 200 + updated entity |
| PATCH | Partial update | Yes | 200 + updated entity |
| DELETE | Remove resource | Yes | 204 / void |
## Standard Endpoint Patterns
```typescript
// Collection operations
'/topics': {
GET: { ... } // List with pagination/filtering
POST: { ... } // Create new resource
}
// Individual resource operations
'/topics/:id': {
GET: { ... } // Get single resource
PUT: { ... } // Replace resource
PATCH: { ... } // Partial update
DELETE: { ... } // Remove resource
}
// Nested resources (use for parent-child relationships)
'/topics/:topicId/messages': {
GET: { ... } // List messages under topic
POST: { ... } // Create message in topic
}
```
## Greedy Path Parameters
Use a greedy param when a single path-param value may itself contain `/`.
This avoids URL-encoding (which the project does not use) and keeps composite
identifiers readable in the path.
**Syntax:** `:<name>*` (trailing `*` on a `:`-prefixed segment).
**Position:** valid as the **last** segment, or **in the middle** of a pattern
anchored by static / plain-param trailing segments. A pattern may contain at
most one greedy param — a second greedy is rejected defensively to keep route
matching unambiguous.
**Semantics:**
- Matches **one or more** consecutive path segments and exposes the raw joined
string (segments rejoined with `/`) as `params.<name>`.
- Does **not** match zero segments — the capture is required.
- The captured value is **not decoded**; any `/`, `::`, `%`, etc. inside it is
preserved verbatim, consistent with the rest of the router.
- There is no `*`-as-any-segment or `**` wildcard — only `:name*`.
**Examples:**
```typescript
// Tail greedy — composite ID at end of path
'/models/:uniqueModelId*'
'/models/openai::gpt-4' { uniqueModelId: 'openai::gpt-4' }
'/models/qwen::qwen/qwen3-vl' { uniqueModelId: 'qwen::qwen/qwen3-vl' }
'/models/fireworks::accounts/fireworks/models/x' { uniqueModelId: 'fireworks::accounts/fireworks/models/x' }
'/models' no match (greedy requires 1 segment)
// Middle greedy — free-form ID wrapped by static anchors
'/models/:uid*/order'
'/models/a/b/c/order' { uid: 'a/b/c' }
'/models/qwen::qwen/order' { uid: 'qwen::qwen' }
'/models/order' no match (greedy requires 1 segment)
'/models/a/b/c' no match (trailing anchor mismatch)
// Mixed leading plain + middle greedy + trailing anchor
'/providers/:providerId/models/:uid*/actions'
'/providers/openai/models/qwen/qwen3-vl/actions' { providerId: 'openai', uid: 'qwen/qwen3-vl' }
```
**When to reach for this:**
- Composite identifiers whose component can include `/`
(e.g. OpenRouter/Fireworks-style model IDs).
- Third-party IDs where you cannot control the character set.
- Attaching sub-actions (`/…/order`, `/…/actions`) to resources whose ID
contains `/`.
**When NOT to use it:**
- For nanoid/UUID-style IDs that never contain `/` — prefer the plain `:id`
form so the route stays strictly 1-to-1 with its shape.
## PATCH vs Dedicated Endpoints
### Decision Criteria
Use this decision tree to determine the appropriate approach:
```
Operation characteristics:
├── Simple field update with no side effects?
│ └── Yes → Use PATCH
├── High-frequency operation with clear business meaning?
│ └── Yes → Use dedicated endpoint (noun-based sub-resource)
├── Operation triggers complex side effects or validation?
│ └── Yes → Use dedicated endpoint
├── Operation creates new resources?
│ └── Yes → Use POST to dedicated endpoint
└── Default → Use PATCH
```
### Guidelines
| Scenario | Approach | Example |
|----------|----------|---------|
| Simple field update | PATCH | `PATCH /messages/:id { data: {...} }` |
| High-frequency + business meaning | Dedicated sub-resource | `PUT /topics/:id/active-node { nodeId }` |
| Complex validation/side effects | Dedicated endpoint | `POST /messages/:id/move { newParentId }` |
| Creates new resources | POST dedicated | `POST /messages/:id/duplicate` |
### Naming for Dedicated Endpoints
- **Prefer noun-based paths** over verb-based when possible
- Treat the operation target as a sub-resource: `/topics/:id/active-node` not `/topics/:id/switch-branch`
- Use POST for actions that create resources or have non-idempotent side effects
- Use PUT for setting/replacing a sub-resource value
### Examples
```typescript
// ✅ Good: Noun-based sub-resource for high-frequency operation
PUT /topics/:id/active-node
{ nodeId: string }
// ✅ Good: Simple field update via PATCH
PATCH /messages/:id
{ data: MessageData }
// ✅ Good: POST for resource creation
POST /messages/:id/duplicate
{ includeDescendants?: boolean }
// ❌ Avoid: Verb in path when noun works
POST /topics/:id/switch-branch // Use PUT /topics/:id/active-node instead
// ❌ Avoid: Dedicated endpoint for simple updates
POST /messages/:id/update-content // Use PATCH /messages/:id instead
```
## Non-CRUD Operations
Use verb-based paths for operations that don't fit CRUD semantics:
> For sortable resources (drag-and-drop ordering), do not invent ad-hoc endpoints — follow the canonical `PATCH /{resource}/:id/order` pattern documented in the [Ordering Guide](./data-ordering-guide.md).
```typescript
// Search
'/topics/search': {
GET: { query: { q: string } }
}
// Statistics / Aggregations
'/topics/stats': {
GET: { response: { total: number, ... } }
}
// Resource actions (state changes, triggers)
'/topics/:id/archive': {
POST: { response: { archived: boolean } }
}
'/topics/:id/duplicate': {
POST: { response: Topic }
}
```
## Query Parameters
| Purpose | Pattern | Example |
|---------|---------|---------|
| Pagination (offset) | `page` + `limit` | `?page=1&limit=20` |
| Pagination (cursor) | `cursor` + `limit` | `?cursor=1700000000000:abc&limit=20` |
| Sorting | `sortBy` + `sortOrder` (see `SortParams` in [api-types.md](api-types.md)) | `?sortBy=createdAt&sortOrder=desc` |
| Filtering | direct field names | `?status=active&type=chat` |
| Search | `q` or `search` | `?q=keyword` |
For offset-vs-cursor selection and the `<key>:<id>` cursor wire format, see the [Pagination Guide](./data-pagination-guide.md).
## Response Status Codes
Use standard HTTP status codes consistently:
| Status | Usage | Example |
|--------|-------|---------|
| 200 OK | Successful GET/PUT/PATCH | Return updated resource |
| 201 Created | Successful POST | Return created resource |
| 202 Accepted | Async task accepted | Return task reference |
| 204 No Content | Successful DELETE | No body |
| 400 Bad Request | Invalid request format | Malformed JSON |
| 400 Invalid Operation | Business rule violation | Delete root without cascade, cycle creation |
| 401 Unauthorized | Authentication required | Missing/invalid token |
| 403 Permission Denied | Insufficient permissions | Access denied to resource |
| 404 Not Found | Resource not found | Invalid ID |
| 409 Conflict | Concurrent modification or data inconsistency | Version conflict, data corruption |
| 422 Unprocessable | Validation failed | Invalid field values |
| 423 Locked | Resource temporarily locked | File being exported |
| 429 Too Many Requests | Rate limit exceeded | Throttling |
| 500 Internal Error | Server error | Unexpected failure |
| 503 Service Unavailable | Service temporarily down | Maintenance mode |
| 504 Timeout | Request timed out | Long-running operation |
### Success Status Constants
Use the `SuccessStatus` constants to avoid magic numbers:
```typescript
import { SuccessStatus } from '@shared/data/api/apiTypes'
SuccessStatus.OK // 200 - Request succeeded
SuccessStatus.CREATED // 201 - Resource created
SuccessStatus.ACCEPTED // 202 - Async task accepted
SuccessStatus.NO_CONTENT // 204 - Success with no body
```
### Handler Status Code Behavior
**Automatic Inference (Default)**
The API server automatically infers status codes based on HTTP method:
| Method | Default Status | Condition |
|--------|----------------|-----------|
| POST | 201 Created | Always |
| DELETE | 204 No Content | When handler returns `undefined` |
| DELETE | 200 OK | When handler returns data |
| GET/PUT/PATCH | 200 OK | Always |
```typescript
// Status codes are inferred automatically - no extra code needed
'/topics': {
POST: async ({ body }) => {
return await topicService.create(body) // Returns 201
}
},
'/topics/:id': {
GET: async ({ params }) => {
return await topicService.getById(params.id) // Returns 200
},
DELETE: async ({ params }) => {
await topicService.delete(params.id)
return undefined // Returns 204
}
}
```
**Custom Status Codes**
Override the default by returning `{ data, status }`:
```typescript
import { SuccessStatus } from '@shared/data/api/apiTypes'
'/async-tasks': {
POST: async ({ body }) => {
const task = await taskService.createAsync(body)
return { data: task, status: SuccessStatus.ACCEPTED } // Returns 202
}
},
'/topics/:id': {
DELETE: async ({ params }) => {
const deleted = await topicService.delete(params.id)
return { data: deleted, status: SuccessStatus.OK } // Returns 200 with data
}
}
```
**Type Safety**
Custom status codes are type-safe - only valid `SuccessStatusCode` values are allowed:
```typescript
// ✅ Valid
return { data: result, status: SuccessStatus.CREATED }
return { data: result, status: SuccessStatus.ACCEPTED }
// ❌ Compile error - 999 is not a valid SuccessStatusCode
return { data: result, status: 999 }
```
## Error Response Format
All error responses follow the `SerializedDataApiError` structure (transmitted via IPC):
```typescript
interface SerializedDataApiError {
code: ErrorCode | string // ErrorCode enum value (e.g., 'NOT_FOUND')
message: string // Human-readable error message
status: number // HTTP status code
details?: Record<string, unknown> // Additional context (e.g., field errors)
requestContext?: { // Request context for debugging
requestId: string
path: string
method: HttpMethod
timestamp?: number
}
// Note: stack trace is NOT transmitted via IPC - rely on Main process logs
}
```
**Examples:**
```typescript
// 404 Not Found
{
code: 'NOT_FOUND',
message: "Topic with id 'abc123' not found",
status: 404,
details: { resource: 'Topic', id: 'abc123' },
requestContext: { requestId: 'req_123', path: '/topics/abc123', method: 'GET' }
}
// 422 Validation Error
{
code: 'VALIDATION_ERROR',
message: 'Request validation failed',
status: 422,
details: {
fieldErrors: {
name: ['Name is required', 'Name must be at least 3 characters'],
email: ['Invalid email format']
}
}
}
// 504 Timeout
{
code: 'TIMEOUT',
message: 'Request timeout: fetch topics (3000ms)',
status: 504,
details: { operation: 'fetch topics', timeoutMs: 3000 }
}
// 400 Invalid Operation
{
code: 'INVALID_OPERATION',
message: 'Invalid operation: delete root message - cascade=true required',
status: 400,
details: { operation: 'delete root message', reason: 'cascade=true required' }
}
```
Use `DataApiErrorFactory` utilities to create consistent errors:
```typescript
import { DataApiErrorFactory, DataApiError } from '@shared/data/api'
// Using factory methods (recommended)
throw DataApiErrorFactory.notFound('Topic', id)
throw DataApiErrorFactory.validation({ name: ['Required'] })
throw DataApiErrorFactory.database(error, 'insert topic')
throw DataApiErrorFactory.timeout('fetch topics', 3000)
throw DataApiErrorFactory.dataInconsistent('Topic', 'parent reference broken')
throw DataApiErrorFactory.invalidOperation('delete root message', 'cascade=true required')
// Check if error is retryable
if (error instanceof DataApiError && error.isRetryable) {
await retry(operation)
}
```
### SQLite Constraint Translation
When a Service writes to the database, SQLite constraint violations (UNIQUE,
FOREIGN KEY, CHECK, NOT NULL) come out as `DrizzleQueryError` with the real
error buried in the `.cause` chain. Translate them to `DataApiError` with
`withSqliteErrors` from `src/main/data/db/sqliteErrors.ts`:
```typescript
import { defaultHandlersFor, withSqliteErrors } from '@data/db/sqliteErrors'
const [row] = await withSqliteErrors(
() => this.db.insert(tagTable).values(dto).returning(),
defaultHandlersFor('Tag', dto.name)
)
```
`defaultHandlersFor` covers the common CRUD case (UNIQUE → 409, FK → 404,
CHECK / NOT NULL → 422). Spread and override any specific kind when needed.
Any unrecognized error is rethrown unchanged — see the file's JSDoc for the
full API contract and the "do not replace pre-validation" discipline note.
## Naming Conventions Summary
| Element | Case | Example |
|---------|------|---------|
| Paths | kebab-case, plural | `/user-settings`, `/topics` |
| Path params | camelCase | `:topicId`, `:messageId` |
| Query params | camelCase | `sortBy`, `pageSize` |
| Body fields | camelCase | `createdAt`, `userName` |
| Error codes | SCREAMING_SNAKE | `NOT_FOUND`, `VALIDATION_ERROR` |
## DataApi Scope & Boundaries
DataApi is exclusively for **persistent business data** backed by SQLite. Operations that do not meet this criteria must use traditional IPC handlers.
### Eligibility Criteria
All three conditions must be met before adding a DataApi endpoint:
1. The operation **reads or writes persistent business data** in a SQLite table
2. The data is **user-created, irreplaceable** (loss would be severe)
3. A **database table schema** exists (or will be created) for this data
If any condition is not met, use an IPC handler in `src/main/ipc.ts` or a lifecycle service instead.
Meeting all three is **necessary but not sufficient** — the operation must also satisfy the side-effect boundary below.
### Hard Rule: No Non-Data Side Effects
A DataApi handler and the service behind it may perform **one kind of effect only: SQLite reads/writes via Drizzle.** Any filesystem, network, process-spawn, child-window, or external-service call on a DataApi code path is a boundary violation — **regardless of how many layers deep it is hidden, and even when the operation also legitimately writes the database.**
DataApiService is the **data** business-logic layer (persisting and querying records), **not** the application's business-logic layer. A non-data side effect bundled into a DB write is still a non-data side effect.
**Mixed operations** ("write a row *and* write a file") are split: a business/lifecycle service in main owns the orchestration and the side effect, calls the Entity Service for the DB part, and is triggered from the renderer via a dedicated IPC channel. The side effect never rides through DataApi.
### Anti-patterns: What Does NOT Belong in DataApi
| Anti-pattern | Why It's Wrong | Correct Approach |
|---|---|---|
| `POST /windows/open` | No database operation, pure side effect | IPC: `IpcChannel.Window_Open` |
| `POST /services/restart` | Process control is not a data operation | IPC: `IpcChannel.Service_Restart` |
| `GET /system/info` | Stateless system query, no persistence | IPC: `IpcChannel.App_Info` |
| `POST /notifications/send` | Triggers external side effect | IPC: `IpcChannel.Notification_Send` |
| `POST /backup/start` | Complex workflow orchestration, not CRUD | IPC: `IpcChannel.Backup_Backup` |
| `POST /auth/login` | OAuth flow, external service integration | IPC: dedicated auth handler |
| `GET /mcp/tools` | Runtime service query, not persisted data | IPC: `IpcChannel.Mcp_ListTools` |
| `POST /jobs` (enqueue) / `DELETE /jobs/:id` (cancel) | Workflow command on `JobManager` infrastructure, not CRUD | Business service in main calls `application.get('JobManager').enqueue(...)` / `.cancel(...)`. For renderer-initiated triggering, use a dedicated IpcApi route (e.g. `knowledge.add_items`). Job DataApi is GET-only. |
### Why Misuse is Harmful
Routing non-data operations through DataApi causes concrete problems:
- **Automatic retry is dangerous for side effects**: DataApi retries failed requests with exponential backoff. Retrying a "send notification" or "restart service" operation means it executes multiple times.
- **SWR caching is meaningless for commands**: `useQuery` caches and deduplicates responses. Caching the result of "open window" or "start backup" has no value and can mask failures.
- **Layered architecture becomes hollow**: Handler → Service → SQLite is designed for data flow. Without a database layer, the Service layer becomes a pass-through wrapper with no purpose.
- **Test patterns don't match**: DataApi tests mock database operations (Drizzle queries, transactions). Side-effectful operations need entirely different test strategies (mocking external services, verifying calls).
## Zod Schema & DTO Conventions
Four rules govern every schema file under `src/shared/data/api/schemas/`. Follow them verbatim.
### A. Use `type` for `XxxSchemas` route tables
```typescript
// ✅ Adopt
export type TagSchemas = {
'/tags': { GET: {...}; POST: {...} }
}
// ✅ With composition
export type GroupSchemas = { '/groups': {...} } & OrderEndpoints<'/groups'>
// ❌ Deprecated
export interface TagSchemas { ... }
```
**Rationale:** route tables never extend or declaration-merge; `type` supports intersection composition and eliminates `interface`/`type` mixing.
### B. Drop `Dto` from Zod schema names; keep `Dto` on TS type names
```typescript
// ✅ Adopt
export const CreateTagSchema = TagSchema.pick({ name: true, color: true })
export type CreateTagDto = z.infer<typeof CreateTagSchema>
// ❌ Deprecated
export const CreateTagDtoSchema = ...
```
**Rationale:** `CreateXxx` already signals "DTO"; `DtoSchema` is NestJS class-based convention, not Zod community practice (tRPC / Colin Hacks / Standard Schema all use `XxxSchema`). Keep `Dto` on type names to distinguish DTOs from entity types (`Tag` vs `CreateTagDto`).
**Exceptions:** value objects (`TagEntityRefSchema`) and reorder body DTOs (`ReorderGroupsSchema`) already match this rule — don't add `Dto`.
### C. Derive DTOs via `.pick()` whitelist with field atoms and `z.strictObject`
```typescript
// 1. Field atoms — share between entity, DTO, query
export const TagNameSchema = z.string().trim().min(1).max(64)
export const TagColorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/)
// 2. Entity with z.strictObject (rejects unknown fields)
export const TagSchema = z.strictObject({
id: z.uuidv4(),
name: TagNameSchema,
color: TagColorSchema.nullable(),
createdAt: z.iso.datetime(),
updatedAt: z.iso.datetime()
})
export type Tag = z.infer<typeof TagSchema>
// 3. Create DTO — whitelist pick
export const CreateTagSchema = TagSchema.pick({ name: true, color: true })
export type CreateTagDto = z.infer<typeof CreateTagSchema>
// 4. Update DTO — chain from Create
export const UpdateTagSchema = CreateTagSchema.partial()
export type UpdateTagDto = z.infer<typeof UpdateTagSchema>
```
**Rules:**
- **Never `.omit(AutoFields)`** — adding an entity field would auto-expose it (overposting risk). Always whitelist via `.pick({...})`.
- **Always `z.strictObject`** on entity schemas — second line of defense against overposting.
- **Update derivation depends on Create's defaults**:
- `UpdateSchema = CreateSchema.partial()` is safe **only when Create has no `.default()`**.
- When Create carries `.default()`, derive Update from the entity directly: `UpdateSchema = EntitySchema.pick(...).partial()` — Zod v4 retains defaults through `.partial()`, and they leak into PATCH bodies otherwise (Zod issues #4799, #5642).
- **Preferred**: keep Zod schemas free of `.default()` and own defaults at the DB or service layer. See [Default Values & Nullability](./best-practice-default-values-and-nullability.md).
- **Zod v4 gotcha:** `.pick()`/`.omit()` strip `.refine()`/`.check()` validators (working as designed, Zod discussion #4706). If entity has cross-field checks, re-attach them after pick via `.refine()` or `.safeExtend()`.
**When to write a DTO by hand instead of picking:**
1. Entity has ≤ 3 fields and no auto-managed columns — pick is noise.
2. Entity is a discriminated union — `.pick`/`.omit` don't support unions.
3. DTO type differs from entity type (e.g., entity stores `Date`, DTO takes ISO string) — reuse field atoms instead.
4. DTO-from-DTO derivation (`UpdateModelSchema = CreateModelSchema.omit({...})`) is fine — Zod officially endorses this and overposting risk doesn't apply (source is already a DTO, not the entity).
**When to extract a `XXX_MUTABLE_FIELDS` constant for `.pick(...)`:**
Extract when **both** conditions hold:
1. Create and Update DTOs share the same pick set (i.e. `UpdateSchema = CreateSchema.partial()`).
2. The pick set has ≥ 5 fields (inline spans multiple lines and hurts readability).
Otherwise inline `.pick({...})`:
- Few fields (≤ 4) — inline is a one-liner, a named constant only adds indirection (see `tags.ts`).
- Create and Update have **different** pick sets — a single `MUTABLE_FIELDS` constant would mislead readers into thinking the sets are shared; pick inline in each DTO instead (see `topics.ts`).
### D. Write every DataApi schema in Zod; no `drizzle-zod`, no pure TS `interface` DTO
All entity schemas and DTOs in `src/shared/data/api/schemas/` MUST be hand-written Zod schemas.
- **No `interface XxxDto`** — violates Electron trust-boundary validation (renderer → main IPC requires runtime validation per Electron security checklist #17).
- **No `drizzle-zod`** — the library is being deprecated in drizzle v1, and its generated schemas have TS type bugs on `.pick()`/`.omit()` that conflict with Rule C.
**Rationale:** DataApi crosses an IPC trust boundary; TS `interface` provides zero runtime defense against schema drift, mass assignment, or a compromised renderer. Zod parse cost (~25µs) is negligible compared to IPC round-trip latency. Schema-first is the industry standard (tRPC, Next.js 13+, Standard Schema Alliance).
**Response types stay as TS `interface`.** Rule D covers **entities and DTOs** — not response shapes. Responses flow `main → renderer`, the **opposite** direction of the IPC trust boundary: main constructs them from trusted state, renderer consumes them after type-checked IPC plumbing. Runtime validation on that edge is cost without security benefit. Examples that correctly stay as `interface`: `DeleteMessageResponse`, `ActiveNodeResponse`, `PersistTemporaryChatResponse`, `TreeResponse`, `BranchMessagesResponse`, `TreeNode`, `SiblingsGroup`, `BranchMessage`.
**Exception:** when a type is **both** a response payload and an entity (e.g., `Topic` is returned from `GET /topics/:id` and also represents a row in the DB), Zod-ify it as an entity per Rule C — the entity role wins.
### E. Default values do not live in Zod schemas
Avoid `.default()` on entity, Create, and Update schemas. Defaults belong at the DB layer (stable values), via Drizzle `$defaultFn` (dynamic per-row values like UUIDs / timestamps), or in the owning service (tunable product values that may evolve). Putting defaults in Zod schemas creates three problems:
| Problem | Why |
|---|---|
| Caller asymmetry | `.default()` runs at `.parse()`. Handler-driven inserts get them; seeders / internal callers don't, producing inconsistent rows. |
| Type duality | `.default()` makes `z.input` and `z.output` diverge — bodies see optional fields, services see required ones. Pairs of `…Body` / `…Dto` types proliferate to hide the gap. |
| PATCH leakage | Zod v4 retains defaults through `.partial()`, so any `UpdateSchema` derived from a `CreateSchema` with defaults materializes them on omitted PATCH fields, overwriting row state (Rule C). |
If a default truly must live in Zod (e.g., a query-string baseline like `page = 1` on `ListXxxQuerySchema`), confine it to the **specific schema** it applies to — never on the entity, Create, or Update schemas.
For the cross-layer placement decision tree, see [Default Values & Nullability](./best-practice-default-values-and-nullability.md).
## Template Path vs Hook Binding
The data hooks (`useQuery`, `useMutation`, `useInfiniteQuery`, `usePaginatedQuery`) accept two equivalent ways to supply path parameters. They produce byte-for-byte identical SWR cache keys, but suit different call-site shapes.
| Form | Use when |
|---|---|
| Concrete path — `useQuery(providerPath(id))` | The id is stable in the caller's scope (props, hook arg, closed over in a single component) |
| Template path — `useQuery('/providers/:providerId', { params: { providerId: id } })` | One hook instance operates on multiple ids over its lifetime (sidebar actions, command palette, URL handlers) |
Pick based on **where the id comes from**, not personal preference:
- `<ProviderSettings providerId={id}>` — id is stable → concrete path (`providerPath(id)`). Template form would add typing noise (`params` on every trigger) without benefit.
- `useProviderActions()` hook exposing `deleteProviderById(id)` — id varies per call → template path. The alternative would be dropping back to imperative `dataApiService.delete(...)` and hand-rolling `invalidate(...)`, which loses `isLoading` / declarative refresh / optimistic rollback.
Don't mix both forms for the same resource inside one module — although cache keys are identical, readers have to hold two mental models. Pick one and stay consistent.
**Concurrent trigger caveat**: a single template `useMutation` instance shares `isMutating`/`error` across all `params`. For true concurrent writes on different ids (e.g., deleting multiple rows in parallel), mount one hook per row bound to a concrete path. See [DataApi in Renderer → Concurrent trigger caveat](./data-api-in-renderer.md#caveat-concurrent-trigger-on-template-usemutation).
## Matcher Semantics: Cache vs DataApi
Cherry Studio has two cache layers with different key shapes and different invalidation matchers. They look similar but **are not interchangeable**:
| Layer | Key shape | Match syntax | Example |
|---|---|---|---|
| **Cache** (`useCache`, `useSharedCache`) | Schema-defined: fixed key or template with `${placeholder}` segments (see [cache-schema-guide.md](./cache-schema-guide.md)) | Concrete key → exact match; template key in `subscribeSharedChange` → regex compiled from template, fires per concrete instance | `subscribeSharedChange('web_search.provider.last_used_key.${providerId}', cb)` |
| **DataApi** (`useQuery`, `useMutation` refresh, `useInvalidateCache`) | `[path, query?]` tuple with REST-style paths | Exact string match on `key[0]` with optional `/*` prefix | `refresh: ['/providers', '/providers/*']` |
Why the two differ:
- **Cache keys are schema-constrained and dot-separated**: `web_search.provider.last_used_key.google`. Template subscription uses a regex derived from the template (each `${}``[\w\-]+`) so a single subscription covers every concrete instance, including ones registered at runtime.
- **DataApi keys mirror REST resource paths**: `['/providers/abc', { limit: 10 }]`. The structure is rigid (it maps to server routes), so a simple exact-or-prefix matcher is enough and more predictable than regex.
**Implication for reviewers**:
- Don't copy a `${}` template from a cache key into `refresh` options. `refresh: ['/providers/${providerId}/*']` is a bug — the `${}` is left as a literal string, not interpolated. Use template literal backticks (`` `/providers/${providerId}/*` ``) or compute the key in the function-form refresh.
- Cache same-value writes short-circuit via `isEqual` from es-toolkit/compat (no broadcast, no subscriber fire). DataApi `refresh` has no such short-circuit — each call triggers a refetch.