- 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. |
||
|---|---|---|
| .. | ||
| schemas | ||
| seeding | ||
| DbService.ts | ||
| README.md | ||
| types.d.ts | ||
Database Schema Guidelines
Naming Conventions
- Table names: Use singular form with snake_case (e.g.,
topic,message,app_state) - Export names: Use
xxxTablepattern (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 toDate.now()on insertupdatedAt: Auto-set on insert, auto-updated on updatedeletedAt:nullby 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
AnySQLiteColumntype 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:
- Identify which relationship is "weaker" - typically the one that can be null or is less critical for data integrity
- Remove the FK constraint from the weaker side - let the application layer handle validation and consistency (this is known as "soft references" pattern)
- 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
DEFERRABLEconstraints (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 insertcreatedAt: Uses$defaultFn()withDate.now(), auto-generated on insertupdatedAt: 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.