Files
CherryHQ-cherry-studio/docs/references/data/data-api-in-main.md
fullex 375478371c refactor(data-schema): tighten createUpdateTimestamps with NOT NULL
Add `.notNull()` to `createdAt` / `updatedAt` in the shared
`createUpdateTimestamps` helper so Drizzle `$inferSelect` produces
`number` instead of the misleading `number | null`. `deletedAt` in
`createUpdateDeleteTimestamps` stays nullable (soft-delete semantics).

Generated migration 0013 rebuilds 26 affected tables via the standard
SQLite table-recreation pattern; FK / CHECK / INDEX constraints are
preserved across rebuild. No backfill is added (project is in the
development phase; null pre-existing rows are accepted as a "wipe DB"
signal rather than engineered around).

Fix upstream in `KnowledgeMappings.toTimestamp` so it returns a
`Date.now()` fallback instead of `undefined` — otherwise future Dexie
-> v2 migrator runs would try to insert undefined into NOT NULL
columns. Three test assertions updated from `undefined` to
`expect.any(Number)`.

Sweep 18 downstream call sites across 9 functions that were carrying a
dead `?? new Date().toISOString()` fallback:

- AssistantService, KnowledgeBaseService, KnowledgeItemService,
  MessageService, TopicService, TranslateHistoryService,
  TranslateLanguageService (the original Pattern A set)
- McpServerService and MiniAppService.rowToMiniApp (reclassified from
  Pattern B: the domain types stay `optional` to accommodate builtin
  literals in the renderer, but `string` assigns legally into
  `string | undefined`, so the switch is safe)

Keep `MiniAppService.builtinToMiniApp` on `timestampToISOOrUndefined`
— its `dbRow?: MiniAppSelect` semantics ("the preference row may not
exist at all") is genuinely optional, not a disguised "nullable column".

Also remove a Pattern C that neither the plan nor the grep audit
caught: `TagService.ensureTagTimestamp` was a self-rolled defense
layer that threw INTERNAL_SERVER_ERROR on null timestamps. The DB
now refuses to produce such rows, so the defense — and the test
named "should surface timestamp anomalies instead of masking them" —
are dead code. Removed both.

Update three docs to reflect the new defaults:

- `services/utils/README.md` — drop the "DB still nullable" table row
  and the predictive paragraph; reframe Pattern B around
  "whole row may not exist"
- `services/utils/rowMappers.ts` JSDoc — same reframing
- `docs/references/data/data-api-in-main.md` — delete the fallback
  code samples and simplify Convention §3
2026-04-21 03:21:05 -07:00

12 KiB

DataApi in Main Process

This guide covers how to implement API handlers and services in the Main process.

Architecture Layers

Handlers → Services → Database
  • Handlers: Thin layer, extract params, call service, transform response
  • Services: Business logic, validation, transaction coordination, data access via Drizzle ORM
  • Database: Drizzle ORM + SQLite

Transport Adapters

ApiServer is transport-agnostic. Adapters in api/core/adapters/ bridge specific transports (IPC, HTTP) to ApiServer. Each adapter implements Disposable for automatic lifecycle cleanup. See IpcAdapter.ts JSDoc for design rationale and extension guide.

Implementing Handlers

Location

src/main/data/api/handlers/

Handler Responsibilities

  • Extract parameters from request
  • Delegate to business service
  • Transform response for IPC
  • NO business logic here

Example Handler

// handlers/topic.ts
import type { ApiImplementation } from '@shared/data/api'
import { topicService } from '@data/services/TopicService'

export const topicHandlers: Partial<ApiImplementation> = {
  '/topics': {
    GET: async ({ query }) => {
      const { page = 1, limit = 20 } = query ?? {}
      return await topicService.list({ page, limit })
    },
    POST: async ({ body }) => {
      return await topicService.create(body)
    }
  },
  '/topics/:id': {
    GET: async ({ params }) => {
      return await topicService.getById(params.id)
    },
    PUT: async ({ params, body }) => {
      return await topicService.replace(params.id, body)
    },
    PATCH: async ({ params, body }) => {
      return await topicService.update(params.id, body)
    },
    DELETE: async ({ params }) => {
      await topicService.delete(params.id)
    }
  }
}

Register Handlers

// handlers/index.ts
import { topicHandlers } from './topic'
import { messageHandlers } from './message'

export const allHandlers: ApiImplementation = {
  ...topicHandlers,
  ...messageHandlers
}

Implementing Services

Location

src/main/data/services/

Service Responsibilities

  • Business validation
  • Transaction coordination
  • Domain workflows
  • Data access via Drizzle ORM

Example Service

// services/TopicService.ts
import { eq, desc, sql } from 'drizzle-orm'
import { application } from '@application'
import { topicTable } from '@data/db/schemas/topic'
import { DataApiErrorFactory } from '@shared/data/api'

export class TopicService {
  private static instance: TopicService

  static getInstance(): TopicService {
    if (!this.instance) {
      this.instance = new TopicService()
    }
    return this.instance
  }

  private get db() {
    return application.get('DbService').getDb()
  }

  async list(options: { page: number; limit: number }) {
    const { page, limit } = options
    const offset = (page - 1) * limit

    const [items, countResult] = await Promise.all([
      this.db.select().from(topicTable)
        .orderBy(desc(topicTable.updatedAt))
        .limit(limit).offset(offset),
      this.db.select({ count: sql<number>`count(*)` }).from(topicTable)
    ])

    return { items, total: countResult[0].count, page, limit }
  }

  async getById(id: string) {
    const [topic] = await this.db.select().from(topicTable)
      .where(eq(topicTable.id, id)).limit(1)
    if (!topic) {
      throw DataApiErrorFactory.notFound('Topic', id)
    }
    return topic
  }

  async create(data: CreateTopicDto) {
    this.validateTopicData(data)
    const [topic] = await this.db.insert(topicTable).values(data).returning()
    return topic
  }

  async update(id: string, data: Partial<UpdateTopicDto>) {
    await this.getById(id) // Throws if not found
    const [topic] = await this.db.update(topicTable)
      .set(data).where(eq(topicTable.id, id)).returning()
    return topic
  }

  async delete(id: string) {
    await this.getById(id) // Throws if not found
    await this.db.delete(topicTable).where(eq(topicTable.id, id))
  }

  private validateTopicData(data: CreateTopicDto) {
    if (!data.name?.trim()) {
      throw DataApiErrorFactory.validation({ name: ['Name is required'] })
    }
  }
}

export const topicService = TopicService.getInstance()

Row → Entity Mapping

Each Entity Service provides a rowToEntity function that bridges a Drizzle row to its domain entity. Use nullsToUndefined (from services/utils/rowMappers.ts) for the SQLite NULL → TypeScript undefined translation.

Standard skeleton:

import { nullsToUndefined, timestampToISO } from './utils/rowMappers'

function rowToMCPServer(row: typeof mcpServerTable.$inferSelect): MCPServer {
  const clean = nullsToUndefined(row)
  return {
    ...clean,
    type: clean.type as MCPServer['type'], // narrow enum
    installSource: clean.installSource as MCPServer['installSource'],
    createdAt: timestampToISO(row.createdAt),
    updatedAt: timestampToISO(row.updatedAt)
  }
}

Audit columns generated by createUpdateTimestamps are DB-level .notNull(), so row.createdAt / row.updatedAt narrow to number and timestampToISO is the default. timestampToISOOrUndefined is reserved for construction paths where the entire source row may be absent (e.g. MiniAppService.builtinToMiniApp merging a builtin definition with an optional dbRow).

Advanced skeleton — preserving T | null fields:

When the domain type declares a field as T | null (e.g. KnowledgeBaseSchema.embeddingModelId: z.string().nullable()), bypass clean for that field and reference row directly. nullsToUndefined narrows all top-level nulls to undefined and would break the T | null contract if the field came from clean.

function rowToKnowledgeBase(row: typeof knowledgeBaseTable.$inferSelect): KnowledgeBase {
  const clean = nullsToUndefined(row)
  return {
    ...clean,
    // Preserve `string | null` contract — bypass clean (which would narrow null → undefined)
    embeddingModelId: row.embeddingModelId,
    createdAt: timestampToISO(row.createdAt),
    updatedAt: timestampToISO(row.updatedAt)
  }
}

Rule of thumb: domain field typed T | null → use row.x; domain field typed T? or T → use clean.x (or ...clean).

When nullsToUndefined + spread is NOT a fit:

Some rowToEntity functions do too much to benefit from spread. Keep them hand-written when any of the following apply:

  • Field renaming: row.parameters → domain parameterSupport (ModelService)
  • Non-undefined fallbacks: ?? [], ?? true, ?? false, ?? anotherField — these need per-field logic anyway
  • Computed / merged fields: authType derivation, apiFeatures merging from defaults (ProviderService)
  • Sensitive data sanitization: apiKeys stripping — ...clean would leak unsanitized values

Conventions:

  1. DB NULL ↔ domain undefined boundary. Domain types under @shared/data/types/* use optional fields (?:) rather than T | null, aligning with the Google TypeScript Style Guide and keeping null from leaking to the renderer via IPC. nullsToUndefined(row) is the only place this translation happens.
  2. Batch vs single-field null handling. For processing an entire row, always use nullsToUndefined(row) + spread — do NOT hand-write per-field ?? undefined. For single values that are NOT from a row (DTO fields, computed values, function returns), inline value ?? undefined is enough — TypeScript narrows T | null to T | undefined automatically at the ?? expression. Do NOT wrap the single-field case in a helper.
  3. Date fields: two helpers, clear boundary. timestampToISO(value: number | Date): string is the default for rowToEntity — audit columns from createUpdateTimestamps are .notNull(), so the DB row hands back a real number. timestampToISOOrUndefined(value: number | Date | null | undefined): string | undefined is reserved for merge paths where the source row itself may be absent (e.g. a builtin/preset definition without a preference row). Do NOT use timestampToISOOrUndefined as a "safer default" — if your input is a DB row, it always has these fields.

For function signature details and design-decision history (e.g. why shallow-not-recursive, why not dnull), see services/utils/README.md.

Service with Transaction

async createTopicWithMessage(data: CreateTopicWithMessageDto) {
  const db = application.get('DbService').getDb()

  return await db.transaction(async (tx) => {
    const [topic] = await tx.insert(topicTable).values(data.topic).returning()

    const [message] = await tx.insert(messageTable).values({
      ...data.message,
      topicId: topic.id
    }).returning()

    return { topic, message }
  })
}

Repository Pattern (Strongly Discouraged)

⚠️ Do NOT create Repository files by default. Services handle both business logic and data access directly via Drizzle ORM. This is an intentional design decision.

Only create a separate Repository when you are 1000% certain it is absolutely necessary — e.g., extremely complex multi-table queries with joins/CTEs that would make the Service unreadable, AND the query logic is reused across multiple services.

If in doubt, keep it in the Service. The overhead of an extra architectural layer is not justified for this project's scale (Electron desktop app + SQLite).

Registry Services (Supplementary)

In rare cases where a handler needs to merge read-only preset data (shipped JSON/TS) with database data, a Registry Service may be introduced. This is uncommon — the vast majority of services are Entity Services.

Registry Services:

  • Do NOT own a database table and do NOT access the database directly
  • Obtain DB data by calling the owning Entity Service
  • Named {Domain}RegistryService (e.g., ProviderRegistryService)
  • Primary data source is static preset data (JSON files, TS constants)
  • All methods are read-only (no inserts, updates, or deletes)

See Layered Preset Pattern for the general architecture.

Error Handling

Using DataApiErrorFactory

import { DataApiErrorFactory } from '@shared/data/api'

// Not found
throw DataApiErrorFactory.notFound('Topic', id)

// Validation error
throw DataApiErrorFactory.validation({
  name: ['Name is required', 'Name must be at least 3 characters'],
  email: ['Invalid email format']
})

// Database error
try {
  await db.insert(table).values(data)
} catch (error) {
  throw DataApiErrorFactory.database(error, 'insert topic')
}

// Invalid operation
throw DataApiErrorFactory.invalidOperation(
  'delete root message',
  'cascade=true required'
)

// Conflict
throw DataApiErrorFactory.conflict('Topic name already exists')

// Timeout
throw DataApiErrorFactory.timeout('fetch topics', 3000)

Adding New Endpoints

Step-by-Step

  1. Define schema in packages/shared/data/api/schemas/
// schemas/topic.ts
export interface TopicSchemas {
  '/topics': {
    GET: { response: PaginatedResponse<Topic> }
    POST: { body: CreateTopicDto; response: Topic }
  }
}
  1. Register schema in schemas/index.ts
export type ApiSchemas = AssertValidSchemas<TopicSchemas & MessageSchemas>
  1. Create service in services/

  2. Implement handler in handlers/

  3. Register handler in handlers/index.ts

Best Practices

  1. Keep handlers thin: Only extract params and call services
  2. Put logic in services: All business rules and data access belong in services
  3. Do NOT create separate Repository files: Services own data access directly via Drizzle ORM
  4. Always use .returning(): Get inserted/updated data without re-querying
  5. Support transactions: Accept optional tx parameter in service methods
  6. Validate in services: Business validation belongs in the service layer
  7. Use error factory: Consistent error creation with DataApiErrorFactory
  8. Use nullsToUndefined in rowToEntity: Canonical SQLite NULL → undefined translation; shallow, not recursive (see Row → Entity Mapping)