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
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-
undefinedfallbacks:?? [],?? true,?? false,?? anotherField— these need per-field logic anyway - Computed / merged fields:
authTypederivation,apiFeaturesmerging from defaults (ProviderService) - Sensitive data sanitization:
apiKeysstripping —...cleanwould leak unsanitized values
Conventions:
- DB NULL ↔ domain
undefinedboundary. Domain types under@shared/data/types/*use optional fields (?:) rather thanT | null, aligning with the Google TypeScript Style Guide and keepingnullfrom leaking to the renderer via IPC.nullsToUndefined(row)is the only place this translation happens. - 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), inlinevalue ?? undefinedis enough — TypeScript narrowsT | nulltoT | undefinedautomatically at the??expression. Do NOT wrap the single-field case in a helper. - Date fields: two helpers, clear boundary.
timestampToISO(value: number | Date): stringis the default forrowToEntity— audit columns fromcreateUpdateTimestampsare.notNull(), so the DB row hands back a realnumber.timestampToISOOrUndefined(value: number | Date | null | undefined): string | undefinedis reserved for merge paths where the source row itself may be absent (e.g. a builtin/preset definition without a preference row). Do NOT usetimestampToISOOrUndefinedas 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
- Define schema in
packages/shared/data/api/schemas/
// schemas/topic.ts
export interface TopicSchemas {
'/topics': {
GET: { response: PaginatedResponse<Topic> }
POST: { body: CreateTopicDto; response: Topic }
}
}
- Register schema in
schemas/index.ts
export type ApiSchemas = AssertValidSchemas<TopicSchemas & MessageSchemas>
-
Create service in
services/ -
Implement handler in
handlers/ -
Register handler in
handlers/index.ts
Best Practices
- Keep handlers thin: Only extract params and call services
- Put logic in services: All business rules and data access belong in services
- Do NOT create separate Repository files: Services own data access directly via Drizzle ORM
- Always use
.returning(): Get inserted/updated data without re-querying - Support transactions: Accept optional
txparameter in service methods - Validate in services: Business validation belongs in the service layer
- Use error factory: Consistent error creation with
DataApiErrorFactory - Use
nullsToUndefinedinrowToEntity: Canonical SQLite NULL →undefinedtranslation; shallow, not recursive (see Row → Entity Mapping)