cherry-studio/src/main/data/db
fullex 44b85fa661 docs(README): enhance foreign key documentation with usage examples
- Added sections on basic usage of foreign keys, self-referencing foreign keys, and circular foreign key references.
- Provided TypeScript code examples to illustrate best practices and avoid common pitfalls.
- Explained the rationale behind using soft references in SQLite for improved data integrity and simplified operations.
2025-12-29 12:00:24 +08:00
..
schemas feat(api): enhance message and topic schemas with new features 2025-12-29 00:42:55 +08:00
seeding feat: CacheService & useCache Hooks 2025-09-15 14:12:41 +08:00
DbService.ts feat: initialize database in app startup and enhance DbService 2025-11-21 23:46:51 +08:00
README.md docs(README): enhance foreign key documentation with usage examples 2025-12-29 12:00:24 +08:00
types.d.ts mv dir to data 2025-08-09 14:30:24 +08:00

Database Schema Guidelines

Naming Conventions

  • Table names: Use singular form with snake_case (e.g., topic, message, app_state)
  • Export names: Use xxxTable pattern (e.g., topicTable, messageTable)
  • Column names: Drizzle auto-infers from property names, no need to specify explicitly

Column Helpers

All helpers are exported from ./schemas/columnHelpers.ts.

Primary Keys

Helper UUID Version Use Case
uuidPrimaryKey() v4 (random) General purpose tables
uuidPrimaryKeyOrdered() v7 (time-ordered) Large tables with time-based queries

Usage:

import { uuidPrimaryKey, uuidPrimaryKeyOrdered } from './columnHelpers'

// General purpose table
export const topicTable = sqliteTable('topic', {
  id: uuidPrimaryKey(),
  name: text(),
  ...
})

// Large table with time-ordered data
export const messageTable = sqliteTable('message', {
  id: uuidPrimaryKeyOrdered(),
  content: text(),
  ...
})

Behavior:

  • ID is auto-generated if not provided during insert
  • Can be manually specified for migration scenarios
  • Use .returning() to get the generated ID after insert

Timestamps

Helper Fields Use Case
createUpdateTimestamps createdAt, updatedAt Tables without soft delete
createUpdateDeleteTimestamps createdAt, updatedAt, deletedAt Tables with soft delete

Usage:

import { createUpdateTimestamps, createUpdateDeleteTimestamps } from './columnHelpers'

// Without soft delete
export const tagTable = sqliteTable('tag', {
  id: uuidPrimaryKey(),
  name: text(),
  ...createUpdateTimestamps
})

// With soft delete
export const topicTable = sqliteTable('topic', {
  id: uuidPrimaryKey(),
  name: text(),
  ...createUpdateDeleteTimestamps
})

Behavior:

  • createdAt: Auto-set to Date.now() on insert
  • updatedAt: Auto-set on insert, auto-updated on update
  • deletedAt: null by default, set to timestamp for soft delete

JSON Fields

For JSON column support, use { mode: 'json' }:

data: text({ mode: 'json' }).$type<MyDataType>()

Drizzle handles JSON serialization/deserialization automatically.

Foreign Keys

Basic Usage

// SET NULL: preserve record when referenced record is deleted
groupId: text().references(() => groupTable.id, { onDelete: 'set null' })

// CASCADE: delete record when referenced record is deleted
topicId: text().references(() => topicTable.id, { onDelete: 'cascade' })

Self-Referencing Foreign Keys

For self-referencing foreign keys (e.g., tree structures with parentId), always use the foreignKey operator in the table's third parameter:

import { foreignKey, sqliteTable, text } from 'drizzle-orm/sqlite-core'

export const messageTable = sqliteTable(
  'message',
  {
    id: uuidPrimaryKeyOrdered(),
    parentId: text(),  // Do NOT use .references() here
    // ...other fields
  },
  (t) => [
    // Use foreignKey operator for self-referencing
    foreignKey({ columns: [t.parentId], foreignColumns: [t.id] }).onDelete('set null')
  ]
)

Why this approach:

  • Avoids TypeScript circular reference issues (no need for AnySQLiteColumn type annotation)
  • More explicit and readable
  • Allows chaining .onDelete() / .onUpdate() actions

Circular Foreign Key References

Avoid circular foreign key references between tables. For example:

// ❌ BAD: Circular FK between tables
// tableA.currentItemId -> tableB.id
// tableB.ownerId -> tableA.id

If you encounter a scenario that seems to require circular references:

  1. Identify which relationship is "weaker" - typically the one that can be null or is less critical for data integrity
  2. Remove the FK constraint from the weaker side - let the application layer handle validation and consistency (this is known as "soft references" pattern)
  3. Document the application-layer constraint in code comments
// ✅ GOOD: Break the cycle by handling one side at application layer
export const topicTable = sqliteTable('topic', {
  id: uuidPrimaryKey(),
  // Application-managed reference (no FK constraint)
  // Validated by TopicService.setCurrentMessage()
  currentMessageId: text(),
})

export const messageTable = sqliteTable('message', {
  id: uuidPrimaryKeyOrdered(),
  // Database-enforced FK
  topicId: text().references(() => topicTable.id, { onDelete: 'cascade' }),
})

Why soft references for SQLite:

  • SQLite does not support DEFERRABLE constraints (unlike PostgreSQL/Oracle)
  • Application-layer validation provides equivalent data integrity
  • Simplifies insert/update operations without transaction ordering concerns

Migrations

Generate migrations after schema changes:

yarn db:migrations:generate

Field Generation Rules

The schema uses Drizzle's auto-generation features. Follow these rules:

Auto-generated fields (NEVER set manually)

  • id: Uses $defaultFn() with UUID v4/v7, auto-generated on insert
  • createdAt: Uses $defaultFn() with Date.now(), auto-generated on insert
  • updatedAt: Uses $defaultFn() and $onUpdateFn(), auto-updated on every update

Using .returning() pattern

Always use .returning() to get inserted/updated data instead of re-querying:

// Good: Use returning()
const [row] = await db.insert(table).values(data).returning()
return rowToEntity(row)

// Avoid: Re-query after insert (unnecessary database round-trip)
await db.insert(table).values({ id, ...data })
return this.getById(id)

Soft delete support

The schema supports soft delete via deletedAt field (see createUpdateDeleteTimestamps). Business logic can choose to use soft delete or hard delete based on requirements.