feat: enhance CreateMessageDto and MessageService for improved parentId handling

- Updated CreateMessageDto to include detailed behavior for the parentId field, allowing for auto-resolution based on topic state, explicit root creation, or attachment to a specified parent message.
- Refactored MessageService to implement the new parentId logic, ensuring proper validation and error handling for message creation based on the topic's current state and existing messages.
- Enhanced transaction safety and clarity in the message insertion process by resolving parentId before inserting new messages.
This commit is contained in:
fullex 2026-01-03 22:47:44 +08:00
parent f2cd361ab8
commit f1b9ab4250
2 changed files with 82 additions and 7 deletions

View File

@ -24,8 +24,20 @@ import type { AssistantMeta, ModelMeta } from '@shared/data/types/meta'
* DTO for creating a new message
*/
export interface CreateMessageDto {
/** Parent message ID (null for root) */
parentId: string | null
/**
* Parent message ID for positioning this message in the conversation tree.
*
* Behavior:
* - `undefined` (omitted): Auto-resolve parent based on topic state:
* - If topic has no messages: create as root (parentId = null)
* - If topic has messages and activeNodeId is set: attach to activeNodeId
* - If topic has messages but no activeNodeId: throw INVALID_OPERATION error
* - `null` (explicit): Create as root message. Throws INVALID_OPERATION if
* topic already has a root message (only one root allowed per topic).
* - `string` (message ID): Attach to specified parent. Throws NOT_FOUND if
* parent doesn't exist, or INVALID_OPERATION if parent belongs to different topic.
*/
parentId?: string | null
/** Message role */
role: MessageRole
/** Message content */

View File

@ -27,7 +27,7 @@ import type {
TreeNode,
TreeResponse
} from '@shared/data/types/message'
import { and, eq, inArray, or, sql } from 'drizzle-orm'
import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm'
const logger = loggerService.withContext('MessageService')
@ -468,27 +468,90 @@ export class MessageService {
const db = dbService.getDb()
return await db.transaction(async (tx) => {
// Verify topic exists
// Step 1: Verify topic exists and fetch its current state.
// We need the topic to check activeNodeId for parentId auto-resolution.
const [topic] = await tx.select().from(topicTable).where(eq(topicTable.id, topicId)).limit(1)
if (!topic) {
throw DataApiErrorFactory.notFound('Topic', topicId)
}
// Verify parent exists if specified
if (dto.parentId) {
// Step 2: Resolve parentId based on the three possible input states:
// - undefined: auto-resolve based on topic state
// - null: explicitly create as root (must validate uniqueness)
// - string: use provided ID (must validate existence and ownership)
let resolvedParentId: string | null
if (dto.parentId === undefined) {
// Auto-resolution mode: Determine parentId based on topic's current state.
// This provides convenience for callers who want to "append" to the conversation
// without needing to know the tree structure.
// Check if topic has any existing messages by querying for at least one.
const [existingMessage] = await tx
.select({ id: messageTable.id })
.from(messageTable)
.where(eq(messageTable.topicId, topicId))
.limit(1)
if (!existingMessage) {
// Topic is empty: This will be the first message, so it becomes the root.
// Root messages have parentId = null.
resolvedParentId = null
} else if (topic.activeNodeId) {
// Topic has messages and an active node: Attach new message as child of activeNodeId.
// This is the typical case for continuing a conversation.
resolvedParentId = topic.activeNodeId
} else {
// Topic has messages but no activeNodeId: This is an ambiguous state.
// We cannot auto-resolve because we don't know where in the tree to attach.
// Require explicit parentId from caller to resolve the ambiguity.
throw DataApiErrorFactory.invalidOperation(
'create message',
'Topic has messages but no activeNodeId. Please specify parentId explicitly.'
)
}
} else if (dto.parentId === null) {
// Explicit root creation: Caller wants to create a root message.
// Each topic can only have one root message (parentId = null).
// Check if a root already exists to enforce this constraint.
const [existingRoot] = await tx
.select({ id: messageTable.id })
.from(messageTable)
.where(and(eq(messageTable.topicId, topicId), isNull(messageTable.parentId)))
.limit(1)
if (existingRoot) {
// Root already exists: Cannot create another root message.
// This enforces the single-root tree structure constraint.
throw DataApiErrorFactory.invalidOperation('create root message', 'Topic already has a root message')
}
resolvedParentId = null
} else {
// Explicit parent ID provided: Validate the parent exists and belongs to this topic.
// This ensures referential integrity within the message tree.
const [parent] = await tx.select().from(messageTable).where(eq(messageTable.id, dto.parentId)).limit(1)
if (!parent) {
// Parent message not found: Cannot attach to non-existent message.
throw DataApiErrorFactory.notFound('Message', dto.parentId)
}
if (parent.topicId !== topicId) {
// Parent belongs to different topic: Cross-topic references are not allowed.
// Each topic's message tree must be self-contained.
throw DataApiErrorFactory.invalidOperation('create message', 'Parent message does not belong to this topic')
}
resolvedParentId = dto.parentId
}
// Step 3: Insert the message using the resolved parentId.
const [row] = await tx
.insert(messageTable)
.values({
topicId,
parentId: dto.parentId,
parentId: resolvedParentId,
role: dto.role,
data: dto.data,
status: dto.status ?? 'pending',