Back to Cherry Studio

API Design Guidelines

docs/references/data/api-design-guidelines.md

2.0.030.9 KB
Original Source

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. This guide covers the shape of paths only.

Path Naming

RuleExampleNotes
Use plural nouns for collections/topics, /messagesResources are collections
Use kebab-case for multi-word paths/user-settingsNot camelCase or snake_case
Express hierarchy via nesting/topics/:topicId/messagesParent-child relationships
Avoid verbs for CRUD operations/topics not /getTopicsHTTP 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:

LayerConventionExample
DB tablesingular snake_caseagent_session
REST route (collection)plural kebab-case/agent-sessions
Schema / entity typesingular PascalCaseAgentSessionEntity
Inferred row typeXxxRow (§5.3)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):

CaseExample
Shared / library resource/skills, /models
Nested sub-resource/agents/:agentId/tasks
Aggregate / derived / non-CRUD/topics/search, /topics/stats

HTTP Method Semantics

MethodPurposeIdempotentTypical Response
GETRetrieve resource(s)Yes200 + data
POSTCreate resourceNo201 + created entity
PUTReplace entire resourceYes200 + updated entity
PATCHPartial updateYes200 + updated entity
DELETERemove resourceYes204 / 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

ScenarioApproachExample
Simple field updatePATCHPATCH /messages/:id { data: {...} }
High-frequency + business meaningDedicated sub-resourcePUT /topics/:id/active-node { nodeId }
Complex validation/side effectsDedicated endpointPOST /messages/:id/move { newParentId }
Creates new resourcesPOST dedicatedPOST /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.

Provider enablement is a narrow exception: PATCH /providers/:providerId atomically moves that provider to the first position only when isEnabled transitions from false to true. This provider-specific invariant does not establish a general permission for resource updates to mutate ordering. Redundant provider true updates preserve the user's existing order, and explicit reorder requests still use the canonical order routes.

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

PurposePatternExample
Pagination (offset)page + limit?page=1&limit=20
Pagination (cursor)cursor + limit?cursor=1700000000000:abc&limit=20
SortingsortBy + sortOrder (see SortParams in api-types.md)?sortBy=createdAt&sortOrder=desc
Filteringdirect field names?status=active&type=chat
Searchq or search?q=keyword

For offset-vs-cursor selection and the <key>:<id> cursor wire format, see the Pagination Guide.

Response Status Codes

Use standard HTTP status codes consistently:

StatusUsageExample
200 OKSuccessful GET/PUT/PATCHReturn updated resource
201 CreatedSuccessful POSTReturn created resource
202 AcceptedAsync task acceptedReturn task reference
204 No ContentSuccessful DELETENo body
400 Bad RequestInvalid request formatMalformed JSON
400 Invalid OperationBusiness rule violationDelete root without cascade, cycle creation
401 UnauthorizedAuthentication requiredMissing/invalid token
403 Permission DeniedInsufficient permissionsAccess denied to resource
404 Not FoundResource not foundInvalid ID
409 ConflictConcurrent modification or data inconsistencyVersion conflict, data corruption
422 UnprocessableValidation failedInvalid field values
423 LockedResource temporarily lockedFile being exported
429 Too Many RequestsRate limit exceededThrottling
500 Internal ErrorServer errorUnexpected failure
503 Service UnavailableService temporarily downMaintenance mode
504 TimeoutRequest timed outLong-running operation

Success Status Constants

Use the SuccessStatus constants to avoid magic numbers:

typescript
import { SuccessStatus } from '@shared/data/api/types'

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:

MethodDefault StatusCondition
POST201 CreatedAlways
DELETE204 No ContentWhen handler returns undefined
DELETE200 OKWhen handler returns data
GET/PUT/PATCH200 OKAlways
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/types'

'/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/errors'

// 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

ElementCaseExample
Pathskebab-case, plural/user-settings, /topics
Path paramscamelCase:topicId, :messageId
Query paramscamelCasesortBy, pageSize
Body fieldscamelCasecreatedAt, userName
Error codesSCREAMING_SNAKENOT_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.

Fenced Exception: Data Change Notification

One strictly fenced exception to the side-effect rule: after a business write successfully commits, the owning data service may publish a read-model observation signal for cross-window data convergence — notifyDataApiDataChange(effects) (src/main/data/dataApiDataChange.ts), broadcasting DataApiDataChangeEffect[] to all windows. Renderers subscribe via dataApiService.onDataChanged(...) / useDataChange(...).

Fences (all hard):

  • Publish only after commit, never inside a transaction; on rollback the call must be unreachable.
  • The notification never participates in write success — a failure must not roll back or otherwise affect committed data.
  • Effects describe endpoint/read-model changes only — no entity rows, field diffs, SQL predicates, or business commands.
  • Not a channel for file, network, process, window-control, or external-service work.
  • Renderer consumers may use it only for fact refetching and local reconciliation.
  • This is NOT a license for DataApi services to carry side effects in general.

Anti-patterns: What Does NOT Belong in DataApi

Anti-patternWhy It's WrongCorrect Approach
POST /windows/openNo database operation, pure side effectIPC: IpcChannel.Window_Open
POST /services/restartProcess control is not a data operationIPC: IpcChannel.Service_Restart
GET /system/infoStateless system query, no persistenceIPC: IpcChannel.App_Info
POST /notifications/sendTriggers external side effectIPC: IpcChannel.Notification_Send
POST /backup/startComplex workflow orchestration, not CRUDIPC: IpcChannel.Backup_Backup
POST /auth/loginOAuth flow, external service integrationIPC: dedicated auth handler
GET /mcp/toolsRuntime service query, not persisted dataIPC: IpcChannel.Mcp_ListTools
POST /jobs (enqueue) / DELETE /jobs/:id (cancel)Workflow command on JobManager infrastructure, not CRUDBusiness 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.
POST/PATCH/DELETE /agents/:agentId/tasks… (schedule mutation)Mixed-effect command (schedule row + business rows + timer), not CRUDIpcApi ai.agent.task.*AgentJobsService. Task 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.
  • 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:

ProblemWhy
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 leakageZod 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.

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.

FormUse 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.

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:

LayerKey shapeMatch syntaxExample
Cache (useCache, useSharedCache)Schema-defined: fixed key or template with ${placeholder} segments (see cache-schema-guide.md)Concrete key → exact match; template key in subscribeSharedChange → regex compiled from template, fires per concrete instancesubscribeSharedChange('web_search.provider.last_used_key.${providerId}', cb)
DataApi (useQuery, useMutation refresh, useInvalidateCache)[path, query?] tuple with REST-style pathsExact string match on key[0] with optional /* prefixrefresh: ['/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.