diff --git a/packages/shared/data/api/schemas/messages.ts b/packages/shared/data/api/schemas/messages.ts index 6cc0417fa6..f659491f93 100644 --- a/packages/shared/data/api/schemas/messages.ts +++ b/packages/shared/data/api/schemas/messages.ts @@ -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 } /** diff --git a/src/main/data/README.md b/src/main/data/README.md index a8c49346a4..dde7f8188e 100644 --- a/src/main/data/README.md +++ b/src/main/data/README.md @@ -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 diff --git a/src/main/data/db/README.md b/src/main/data/db/README.md index 720348e666..ab772615dc 100644 --- a/src/main/data/db/README.md +++ b/src/main/data/db/README.md @@ -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. diff --git a/src/main/data/db/schemas/columnHelpers.ts b/src/main/data/db/schemas/columnHelpers.ts index c5ea83804c..61a596602d 100644 --- a/src/main/data/db/schemas/columnHelpers.ts +++ b/src/main/data/db/schemas/columnHelpers.ts @@ -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' diff --git a/src/main/data/db/schemas/message.ts b/src/main/data/db/schemas/message.ts index 08545b9f9b..bbb07277e7 100644 --- a/src/main/data/db/schemas/message.ts +++ b/src/main/data/db/schemas/message.ts @@ -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), diff --git a/src/main/data/db/schemas/topic.ts b/src/main/data/db/schemas/topic.ts index b121c5405d..3f19ae7a7f 100644 --- a/src/main/data/db/schemas/topic.ts +++ b/src/main/data/db/schemas/topic.ts @@ -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 diff --git a/src/main/data/services/MessageService.ts b/src/main/data/services/MessageService.ts index 919a5b1df0..4093aad802 100644 --- a/src/main/data/services/MessageService.ts +++ b/src/main/data/services/MessageService.ts @@ -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 { 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 = { - updatedAt: Date.now() - } + const updates: Partial = {} 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 `) diff --git a/src/main/data/services/TopicService.ts b/src/main/data/services/TopicService.ts index 5213132e55..b660f6f09a 100644 --- a/src/main/data/services/TopicService.ts +++ b/src/main/data/services/TopicService.ts @@ -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 { 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 { 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() + 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 = { - updatedAt: Date.now() - } + const updates: Partial = {} 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 { 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 })