feat(api): enhance message and topic schemas with new features

- Added `setAsActive` property to `CreateMessageDto` for controlling active node status in topics.
- Updated `messageTable` and `topicTable` schemas to include foreign key constraints and improved handling of active node references.
- Refactored message and topic service methods to utilize `.returning()` for better data retrieval after inserts and updates.
- Implemented hard delete functionality for messages and topics, replacing soft delete logic to ensure data integrity.
This commit is contained in:
fullex 2025-12-29 00:42:55 +08:00
parent 425f81a882
commit 9c47937714
8 changed files with 157 additions and 153 deletions

View File

@ -46,6 +46,8 @@ export interface CreateMessageDto {
traceId?: string
/** Statistics */
stats?: MessageStats
/** Set this message as the active node in the topic (default: true) */
setAsActive?: boolean
}
/**

View File

@ -220,6 +220,7 @@ export class SimpleService extends BaseService {
- Table definitions using Drizzle ORM
- Follow naming convention: `{entity}Table` exports
- Use `crudTimestamps` helper for timestamp fields
- See [db/README.md](./db/README.md#field-generation-rules) for detailed field generation rules and `.returning()` pattern
### Current Tables
- `preference`: User configuration storage

View File

@ -103,3 +103,32 @@ Generate migrations after schema changes:
```bash
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:
```typescript
// 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.

View File

@ -1,3 +1,12 @@
/**
* Column helper utilities for Drizzle schemas
*
* USAGE RULES:
* - DO NOT manually set id, createdAt, or updatedAt - they are auto-generated
* - Use .returning() to get inserted/updated rows instead of re-querying
* - See db/README.md for detailed field generation rules
*/
import { integer, text } from 'drizzle-orm/sqlite-core'
import { v4 as uuidv4, v7 as uuidv7 } from 'uuid'

View File

@ -1,7 +1,7 @@
import type { MessageData, MessageStats } from '@shared/data/types/message'
import type { AssistantMeta, ModelMeta } from '@shared/data/types/meta'
import { sql } from 'drizzle-orm'
import { check, index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { check, foreignKey, index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { createUpdateDeleteTimestamps, uuidPrimaryKeyOrdered } from './columnHelpers'
import { topicTable } from './topic'
@ -18,8 +18,7 @@ export const messageTable = sqliteTable(
{
id: uuidPrimaryKeyOrdered(),
// Adjacency list parent reference for tree structure
// SET NULL: preserve child messages when parent is deleted
parentId: text().references(() => messageTable.id, { onDelete: 'set null' }),
parentId: text(),
// FK to topic - CASCADE: delete messages when topic is deleted
topicId: text()
.notNull()
@ -53,6 +52,8 @@ export const messageTable = sqliteTable(
...createUpdateDeleteTimestamps
},
(t) => [
// Foreign keys
foreignKey({ columns: [t.parentId], foreignColumns: [t.id] }).onDelete('set null'),
// Indexes
index('message_parent_id_idx').on(t.parentId),
index('message_topic_created_idx').on(t.topicId, t.createdAt),

View File

@ -3,7 +3,7 @@ import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { createUpdateDeleteTimestamps, uuidPrimaryKey } from './columnHelpers'
import { groupTable } from './group'
import { messageTable } from './message'
// import { messageTable } from './message'
/**
* Topic table - stores conversation topics/threads
@ -26,7 +26,8 @@ export const topicTable = sqliteTable(
prompt: text(),
// Active node ID in the message tree
// SET NULL: reset to null when the referenced message is deleted
activeNodeId: text().references(() => messageTable.id, { onDelete: 'set null' }),
activeNodeId: text(),
// .references(() => messageTable.id, { onDelete: 'set null' }),
// FK to group table for organization
// SET NULL: preserve topic when group is deleted

View File

@ -22,8 +22,7 @@ import type {
TreeNode,
TreeResponse
} from '@shared/data/types/message'
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
import { v7 as uuidv7 } from 'uuid'
import { eq, inArray, sql } from 'drizzle-orm'
const logger = loggerService.withContext('MessageService')
@ -126,10 +125,7 @@ export class MessageService {
const activeNodeId = options.nodeId || topic.activeNodeId
// Get all messages for this topic
const allMessages = await db
.select()
.from(messageTable)
.where(and(eq(messageTable.topicId, topicId), isNull(messageTable.deletedAt)))
const allMessages = await db.select().from(messageTable).where(eq(messageTable.topicId, topicId))
if (allMessages.length === 0) {
return { nodes: [], siblingsGroups: [], activeNodeId: null }
@ -249,10 +245,7 @@ export class MessageService {
}
// Get all messages for this topic
const allMessages = await db
.select()
.from(messageTable)
.where(and(eq(messageTable.topicId, topicId), isNull(messageTable.deletedAt)))
const allMessages = await db.select().from(messageTable).where(eq(messageTable.topicId, topicId))
if (allMessages.length === 0) {
return { messages: [], activeNodeId: null }
@ -335,11 +328,7 @@ export class MessageService {
async getById(id: string): Promise<Message> {
const db = dbService.getDb()
const [row] = await db
.select()
.from(messageTable)
.where(and(eq(messageTable.id, id), isNull(messageTable.deletedAt)))
.limit(1)
const [row] = await db.select().from(messageTable).where(eq(messageTable.id, id)).limit(1)
if (!row) {
throw DataApiErrorFactory.notFound('Message', id)
@ -363,41 +352,39 @@ export class MessageService {
// Verify parent exists if specified
if (dto.parentId) {
const [parent] = await db
.select()
.from(messageTable)
.where(and(eq(messageTable.id, dto.parentId), isNull(messageTable.deletedAt)))
.limit(1)
const [parent] = await db.select().from(messageTable).where(eq(messageTable.id, dto.parentId)).limit(1)
if (!parent) {
throw DataApiErrorFactory.notFound('Message', dto.parentId)
}
}
const now = Date.now()
const id = uuidv7()
const [row] = await db
.insert(messageTable)
.values({
topicId,
parentId: dto.parentId,
role: dto.role,
data: dto.data,
status: dto.status ?? 'pending',
siblingsGroupId: dto.siblingsGroupId ?? 0,
assistantId: dto.assistantId,
assistantMeta: dto.assistantMeta,
modelId: dto.modelId,
modelMeta: dto.modelMeta,
traceId: dto.traceId,
stats: dto.stats
})
.returning()
await db.insert(messageTable).values({
id,
topicId,
parentId: dto.parentId,
role: dto.role,
data: dto.data,
status: dto.status ?? 'pending',
siblingsGroupId: dto.siblingsGroupId ?? 0,
assistantId: dto.assistantId,
assistantMeta: dto.assistantMeta,
modelId: dto.modelId,
modelMeta: dto.modelMeta,
traceId: dto.traceId,
stats: dto.stats,
createdAt: now,
updatedAt: now
})
// Update activeNodeId if setAsActive is not explicitly false
if (dto.setAsActive !== false) {
await db.update(topicTable).set({ activeNodeId: row.id }).where(eq(topicTable.id, topicId))
}
logger.info('Created message', { id, topicId, role: dto.role })
logger.info('Created message', { id: row.id, topicId, role: dto.role, setAsActive: dto.setAsActive !== false })
return this.getById(id)
return rowToMessage(row)
}
/**
@ -419,11 +406,7 @@ export class MessageService {
}
// Verify new parent exists
const [parent] = await db
.select()
.from(messageTable)
.where(and(eq(messageTable.id, dto.parentId), isNull(messageTable.deletedAt)))
.limit(1)
const [parent] = await db.select().from(messageTable).where(eq(messageTable.id, dto.parentId)).limit(1)
if (!parent) {
throw DataApiErrorFactory.notFound('Message', dto.parentId)
@ -432,24 +415,22 @@ export class MessageService {
}
// Build update object
const updates: Partial<typeof messageTable.$inferInsert> = {
updatedAt: Date.now()
}
const updates: Partial<typeof messageTable.$inferInsert> = {}
if (dto.data !== undefined) updates.data = dto.data
if (dto.parentId !== undefined) updates.parentId = dto.parentId
if (dto.siblingsGroupId !== undefined) updates.siblingsGroupId = dto.siblingsGroupId
if (dto.status !== undefined) updates.status = dto.status
await db.update(messageTable).set(updates).where(eq(messageTable.id, id))
const [row] = await db.update(messageTable).set(updates).where(eq(messageTable.id, id)).returning()
logger.info('Updated message', { id, changes: Object.keys(dto) })
return this.getById(id)
return rowToMessage(row)
}
/**
* Delete a message
* Delete a message (hard delete)
*/
async delete(id: string, cascade: boolean = false): Promise<{ deletedIds: string[]; reparentedIds?: string[] }> {
const db = dbService.getDb()
@ -464,37 +445,29 @@ export class MessageService {
throw DataApiErrorFactory.invalidOperation('delete root message', 'cascade=true required')
}
const now = Date.now()
if (cascade) {
// Get all descendants
const descendantIds = await this.getDescendantIds(id)
const allIds = [id, ...descendantIds]
// Soft delete all
await db.update(messageTable).set({ deletedAt: now }).where(inArray(messageTable.id, allIds))
// Hard delete all
await db.delete(messageTable).where(inArray(messageTable.id, allIds))
logger.info('Cascade deleted messages', { rootId: id, count: allIds.length })
return { deletedIds: allIds }
} else {
// Reparent children to this message's parent
const children = await db
.select({ id: messageTable.id })
.from(messageTable)
.where(and(eq(messageTable.parentId, id), isNull(messageTable.deletedAt)))
const children = await db.select({ id: messageTable.id }).from(messageTable).where(eq(messageTable.parentId, id))
const childIds = children.map((c) => c.id)
if (childIds.length > 0) {
await db
.update(messageTable)
.set({ parentId: message.parentId, updatedAt: now })
.where(inArray(messageTable.id, childIds))
await db.update(messageTable).set({ parentId: message.parentId }).where(inArray(messageTable.id, childIds))
}
// Soft delete this message
await db.update(messageTable).set({ deletedAt: now }).where(eq(messageTable.id, id))
// Hard delete this message
await db.delete(messageTable).where(eq(messageTable.id, id))
logger.info('Deleted message with reparenting', { id, reparentedCount: childIds.length })
@ -511,11 +484,10 @@ export class MessageService {
// Use recursive query to get all descendants
const result = await db.all<{ id: string }>(sql`
WITH RECURSIVE descendants AS (
SELECT id FROM message WHERE parent_id = ${id} AND deleted_at IS NULL
SELECT id FROM message WHERE parent_id = ${id}
UNION ALL
SELECT m.id FROM message m
INNER JOIN descendants d ON m.parent_id = d.id
WHERE m.deleted_at IS NULL
)
SELECT id FROM descendants
`)

View File

@ -14,8 +14,7 @@ import { loggerService } from '@logger'
import { DataApiErrorFactory } from '@shared/data/api'
import type { CreateTopicDto, UpdateTopicDto } from '@shared/data/api/schemas/topics'
import type { Topic } from '@shared/data/types/topic'
import { and, eq, isNull } from 'drizzle-orm'
import { v4 as uuidv4, v7 as uuidv7 } from 'uuid'
import { eq } from 'drizzle-orm'
import { messageService } from './MessageService'
@ -60,11 +59,7 @@ export class TopicService {
async getById(id: string): Promise<Topic> {
const db = dbService.getDb()
const [row] = await db
.select()
.from(topicTable)
.where(and(eq(topicTable.id, id), isNull(topicTable.deletedAt)))
.limit(1)
const [row] = await db.select().from(topicTable).where(eq(topicTable.id, id)).limit(1)
if (!row) {
throw DataApiErrorFactory.notFound('Topic', id)
@ -78,12 +73,8 @@ export class TopicService {
*/
async create(dto: CreateTopicDto): Promise<Topic> {
const db = dbService.getDb()
const now = Date.now()
const id = uuidv4()
// If forking from existing node, copy the path
let activeNodeId: string | null = null
if (dto.sourceNodeId) {
// Verify source node exists
try {
@ -95,70 +86,76 @@ export class TopicService {
// Get path from root to source node
const path = await messageService.getPathToNode(dto.sourceNodeId)
// Create new topic first
await db.insert(topicTable).values({
id,
name: dto.name,
assistantId: dto.assistantId,
assistantMeta: dto.assistantMeta,
prompt: dto.prompt,
groupId: dto.groupId,
createdAt: now,
updatedAt: now
})
// Create new topic first using returning() to get the id
const [topicRow] = await db
.insert(topicTable)
.values({
name: dto.name,
assistantId: dto.assistantId,
assistantMeta: dto.assistantMeta,
prompt: dto.prompt,
groupId: dto.groupId
})
.returning()
// Copy messages with new IDs
const topicId = topicRow.id
// Copy messages with new IDs using returning()
const idMapping = new Map<string, string>()
let activeNodeId: string | null = null
for (const message of path) {
const newId = uuidv7()
const newParentId = message.parentId ? idMapping.get(message.parentId) || null : null
idMapping.set(message.id, newId)
const [messageRow] = await db
.insert(messageTable)
.values({
topicId,
parentId: newParentId,
role: message.role,
data: message.data,
status: message.status,
siblingsGroupId: 0, // Simplify multi-model to normal node
assistantId: message.assistantId,
assistantMeta: message.assistantMeta,
modelId: message.modelId,
modelMeta: message.modelMeta,
traceId: null,
stats: null
})
.returning()
await db.insert(messageTable).values({
id: newId,
topicId: id,
parentId: newParentId,
role: message.role,
data: message.data,
status: message.status,
siblingsGroupId: 0, // Simplify multi-model to normal node
assistantId: message.assistantId,
assistantMeta: message.assistantMeta,
modelId: message.modelId,
modelMeta: message.modelMeta,
traceId: null, // Clear trace ID
stats: null, // Clear stats
createdAt: now,
updatedAt: now
})
// Last node becomes the active node
activeNodeId = newId
idMapping.set(message.id, messageRow.id)
activeNodeId = messageRow.id
}
// Update topic with active node
await db.update(topicTable).set({ activeNodeId }).where(eq(topicTable.id, id))
await db.update(topicTable).set({ activeNodeId }).where(eq(topicTable.id, topicId))
logger.info('Created topic by forking', { id, sourceNodeId: dto.sourceNodeId, messageCount: path.length })
} else {
// Create empty topic
await db.insert(topicTable).values({
id,
name: dto.name,
assistantId: dto.assistantId,
assistantMeta: dto.assistantMeta,
prompt: dto.prompt,
groupId: dto.groupId,
createdAt: now,
updatedAt: now
logger.info('Created topic by forking', {
id: topicId,
sourceNodeId: dto.sourceNodeId,
messageCount: path.length
})
logger.info('Created empty topic', { id })
}
return this.getById(topicId)
} else {
// Create empty topic using returning()
const [row] = await db
.insert(topicTable)
.values({
name: dto.name,
assistantId: dto.assistantId,
assistantMeta: dto.assistantMeta,
prompt: dto.prompt,
groupId: dto.groupId
})
.returning()
return this.getById(id)
logger.info('Created empty topic', { id: row.id })
return rowToTopic(row)
}
}
/**
@ -171,9 +168,7 @@ export class TopicService {
await this.getById(id)
// Build update object
const updates: Partial<typeof topicTable.$inferInsert> = {
updatedAt: Date.now()
}
const updates: Partial<typeof topicTable.$inferInsert> = {}
if (dto.name !== undefined) updates.name = dto.name
if (dto.isNameManuallyEdited !== undefined) updates.isNameManuallyEdited = dto.isNameManuallyEdited
@ -185,15 +180,15 @@ export class TopicService {
if (dto.isPinned !== undefined) updates.isPinned = dto.isPinned
if (dto.pinnedOrder !== undefined) updates.pinnedOrder = dto.pinnedOrder
await db.update(topicTable).set(updates).where(eq(topicTable.id, id))
const [row] = await db.update(topicTable).set(updates).where(eq(topicTable.id, id)).returning()
logger.info('Updated topic', { id, changes: Object.keys(dto) })
return this.getById(id)
return rowToTopic(row)
}
/**
* Delete a topic and all its messages
* Delete a topic and all its messages (hard delete)
*/
async delete(id: string): Promise<void> {
const db = dbService.getDb()
@ -201,13 +196,11 @@ export class TopicService {
// Verify topic exists
await this.getById(id)
const now = Date.now()
// Hard delete all messages first (due to foreign key)
await db.delete(messageTable).where(eq(messageTable.topicId, id))
// Soft delete all messages
await db.update(messageTable).set({ deletedAt: now }).where(eq(messageTable.topicId, id))
// Soft delete topic
await db.update(topicTable).set({ deletedAt: now }).where(eq(topicTable.id, id))
// Hard delete topic
await db.delete(topicTable).where(eq(topicTable.id, id))
logger.info('Deleted topic', { id })
}
@ -222,18 +215,14 @@ export class TopicService {
await this.getById(topicId)
// Verify node exists and belongs to this topic
const [message] = await db
.select()
.from(messageTable)
.where(and(eq(messageTable.id, nodeId), eq(messageTable.topicId, topicId), isNull(messageTable.deletedAt)))
.limit(1)
const [message] = await db.select().from(messageTable).where(eq(messageTable.id, nodeId)).limit(1)
if (!message) {
if (!message || message.topicId !== topicId) {
throw DataApiErrorFactory.notFound('Message', nodeId)
}
// Update active node
await db.update(topicTable).set({ activeNodeId: nodeId, updatedAt: Date.now() }).where(eq(topicTable.id, topicId))
await db.update(topicTable).set({ activeNodeId: nodeId }).where(eq(topicTable.id, topicId))
logger.info('Set active node', { topicId, nodeId })