From d6ec115348d0581fc2e6729298db7f31c776d1d6 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 7 Apr 2026 16:11:31 -0700 Subject: [PATCH 1/3] v0.6.29: login improvements, posthog telemetry (#4026) * feat(posthog): Add tracking on mothership abort (#4023) Co-authored-by: Theodore Li * fix(login): fix captcha headers for manual login (#4025) * fix(signup): fix turnstile key loading * fix(login): fix captcha header passing * Catch user already exists, remove login form captcha --- apps/sim/app/(auth)/signup/signup-form.tsx | 11 +++-------- .../app/workspace/[workspaceId]/home/home.tsx | 12 ++++++++++-- .../w/[workflowId]/components/panel/panel.tsx | 19 ++++++++++++++++++- apps/sim/lib/posthog/events.ts | 5 +++++ 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 55a0508ec1b..afb27cd729a 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -270,10 +270,8 @@ function SignupFormContent({ name: sanitizedName, }, { - fetchOptions: { - headers: { - ...(token ? { 'x-captcha-response': token } : {}), - }, + headers: { + ...(token ? { 'x-captcha-response': token } : {}), }, onError: (ctx) => { logger.error('Signup error:', ctx.error) @@ -282,10 +280,7 @@ function SignupFormContent({ let errorCode = 'unknown' if (ctx.error.code?.includes('USER_ALREADY_EXISTS')) { errorCode = 'user_already_exists' - errorMessage.push( - 'An account with this email already exists. Please sign in instead.' - ) - setEmailError(errorMessage[0]) + setEmailError('An account with this email already exists. Please sign in instead.') } else if ( ctx.error.code?.includes('BAD_REQUEST') || ctx.error.message?.includes('Email and password sign up is not enabled') diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index d76f17ff454..38367339197 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -223,6 +223,14 @@ export function Home({ chatId }: HomeProps = {}) { posthogRef.current = posthog }, [posthog]) + const handleStopGeneration = useCallback(() => { + captureEvent(posthogRef.current, 'task_generation_aborted', { + workspace_id: workspaceId, + view: 'mothership', + }) + stopGeneration() + }, [stopGeneration, workspaceId]) + const handleSubmit = useCallback( (text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => { const trimmed = text.trim() @@ -334,7 +342,7 @@ export function Home({ chatId }: HomeProps = {}) { defaultValue={initialPrompt} onSubmit={handleSubmit} isSending={isSending} - onStopGeneration={stopGeneration} + onStopGeneration={handleStopGeneration} userId={session?.user?.id} onContextAdd={handleContextAdd} /> @@ -359,7 +367,7 @@ export function Home({ chatId }: HomeProps = {}) { isSending={isSending} isReconnecting={isReconnecting} onSubmit={handleSubmit} - onStopGeneration={stopGeneration} + onStopGeneration={handleStopGeneration} messageQueue={messageQueue} onRemoveQueuedMessage={removeFromQueue} onSendQueuedMessage={sendNow} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 4d485c763ce..da51910789b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -4,6 +4,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { History, Plus, Square } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' +import { usePostHog } from 'posthog-js/react' import { useShallow } from 'zustand/react/shallow' import { BubbleChatClose, @@ -33,6 +34,7 @@ import { import { Lock, Unlock, Upload } from '@/components/emcn/icons' import { VariableIcon } from '@/components/icons' import { useSession } from '@/lib/auth/auth-client' +import { captureEvent } from '@/lib/posthog/client' import { generateWorkflowJson } from '@/lib/workflows/operations/import-export' import { ConversationListItem } from '@/app/workspace/[workspaceId]/components' import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components' @@ -101,6 +103,9 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel const params = useParams() const workspaceId = propWorkspaceId ?? (params.workspaceId as string) + const posthog = usePostHog() + const posthogRef = useRef(posthog) + const panelRef = useRef(null) const fileInputRef = useRef(null) const { activeTab, setActiveTab, panelWidth, _hasHydrated, setHasHydrated } = usePanelStore( @@ -264,6 +269,10 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel loadCopilotChats() }, [loadCopilotChats]) + useEffect(() => { + posthogRef.current = posthog + }, [posthog]) + const handleCopilotSelectChat = useCallback((chat: { id: string; title: string | null }) => { setCopilotChatId(chat.id) setCopilotChatTitle(chat.title) @@ -394,6 +403,14 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel [copilotEditQueuedMessage] ) + const handleCopilotStopGeneration = useCallback(() => { + captureEvent(posthogRef.current, 'task_generation_aborted', { + workspace_id: workspaceId, + view: 'copilot', + }) + copilotStopGeneration() + }, [copilotStopGeneration, workspaceId]) + const handleCopilotSubmit = useCallback( (text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => { const trimmed = text.trim() @@ -833,7 +850,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel isSending={copilotIsSending} isReconnecting={copilotIsReconnecting} onSubmit={handleCopilotSubmit} - onStopGeneration={copilotStopGeneration} + onStopGeneration={handleCopilotStopGeneration} messageQueue={copilotMessageQueue} onRemoveQueuedMessage={copilotRemoveFromQueue} onSendQueuedMessage={copilotSendNow} diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index 537a9864282..faf9895bf62 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -378,6 +378,11 @@ export interface PostHogEventMap { workspace_id: string } + task_generation_aborted: { + workspace_id: string + view: 'mothership' | 'copilot' + } + task_message_sent: { workspace_id: string has_attachments: boolean From 0ad209ffb73d5a722f5953c672eb3e92b514ac2a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 13 May 2026 19:46:18 -0700 Subject: [PATCH 2/3] improvement(db): add session statement/lock timeouts; simplify KB doc tx --- apps/sim/lib/knowledge/documents/service.ts | 193 ++++++++++---------- apps/sim/lib/workspaces/lifecycle.test.ts | 1 + apps/sim/lib/workspaces/lifecycle.ts | 7 + packages/db/db.ts | 8 + 4 files changed, 111 insertions(+), 98 deletions(-) diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 49675608309..99ffb6ec821 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -766,99 +766,99 @@ export async function createDocumentRecords( knowledgeBaseId: string, requestId: string ): Promise { - return await db.transaction(async (tx) => { - await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`) - - const kb = await tx - .select({ id: knowledgeBase.id }) - .from(knowledgeBase) - .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) - .limit(1) + // No tx wrapper: the bulk `db.insert(...).values([...])` is a single statement + // and atomic by Postgres. The KB FK constraint fails loud if the KB is + // concurrently deleted, so an explicit FOR UPDATE lock is unnecessary and + // doubles per-call pool checkouts. + const kb = await db + .select({ id: knowledgeBase.id }) + .from(knowledgeBase) + .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) + .limit(1) - if (kb.length === 0) { - throw new Error('Knowledge base not found') - } + if (kb.length === 0) { + throw new Error('Knowledge base not found') + } - const now = new Date() - const documentRecords = [] - const returnData: DocumentData[] = [] + const now = new Date() + const documentRecords = [] + const returnData: DocumentData[] = [] - for (const docData of documents) { - const documentId = generateId() + for (const docData of documents) { + const documentId = generateId() - let processedTags: Partial = {} + let processedTags: Partial = {} - if (docData.documentTagsData) { - try { - const tagData = JSON.parse(docData.documentTagsData) - if (Array.isArray(tagData)) { - processedTags = await processDocumentTags(knowledgeBaseId, tagData, requestId) - } - } catch (error) { - if (error instanceof SyntaxError) { - logger.warn(`[${requestId}] Failed to parse documentTagsData for bulk document:`, error) - } else { - throw error - } + if (docData.documentTagsData) { + try { + const tagData = JSON.parse(docData.documentTagsData) + if (Array.isArray(tagData)) { + processedTags = await processDocumentTags(knowledgeBaseId, tagData, requestId) + } + } catch (error) { + if (error instanceof SyntaxError) { + logger.warn(`[${requestId}] Failed to parse documentTagsData for bulk document:`, error) + } else { + throw error } } + } - const newDocument = { - id: documentId, - knowledgeBaseId, - filename: docData.filename, - fileUrl: docData.fileUrl, - fileSize: docData.fileSize, - mimeType: docData.mimeType, - chunkCount: 0, - tokenCount: 0, - characterCount: 0, - processingStatus: 'pending' as const, - enabled: true, - uploadedAt: now, - tag1: processedTags.tag1 ?? docData.tag1 ?? null, - tag2: processedTags.tag2 ?? docData.tag2 ?? null, - tag3: processedTags.tag3 ?? docData.tag3 ?? null, - tag4: processedTags.tag4 ?? docData.tag4 ?? null, - tag5: processedTags.tag5 ?? docData.tag5 ?? null, - tag6: processedTags.tag6 ?? docData.tag6 ?? null, - tag7: processedTags.tag7 ?? docData.tag7 ?? null, - number1: processedTags.number1 ?? null, - number2: processedTags.number2 ?? null, - number3: processedTags.number3 ?? null, - number4: processedTags.number4 ?? null, - number5: processedTags.number5 ?? null, - date1: processedTags.date1 ?? null, - date2: processedTags.date2 ?? null, - boolean1: processedTags.boolean1 ?? null, - boolean2: processedTags.boolean2 ?? null, - boolean3: processedTags.boolean3 ?? null, - } - - documentRecords.push(newDocument) - returnData.push({ - documentId, - filename: docData.filename, - fileUrl: docData.fileUrl, - fileSize: docData.fileSize, - mimeType: docData.mimeType, - }) + const newDocument = { + id: documentId, + knowledgeBaseId, + filename: docData.filename, + fileUrl: docData.fileUrl, + fileSize: docData.fileSize, + mimeType: docData.mimeType, + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + processingStatus: 'pending' as const, + enabled: true, + uploadedAt: now, + tag1: processedTags.tag1 ?? docData.tag1 ?? null, + tag2: processedTags.tag2 ?? docData.tag2 ?? null, + tag3: processedTags.tag3 ?? docData.tag3 ?? null, + tag4: processedTags.tag4 ?? docData.tag4 ?? null, + tag5: processedTags.tag5 ?? docData.tag5 ?? null, + tag6: processedTags.tag6 ?? docData.tag6 ?? null, + tag7: processedTags.tag7 ?? docData.tag7 ?? null, + number1: processedTags.number1 ?? null, + number2: processedTags.number2 ?? null, + number3: processedTags.number3 ?? null, + number4: processedTags.number4 ?? null, + number5: processedTags.number5 ?? null, + date1: processedTags.date1 ?? null, + date2: processedTags.date2 ?? null, + boolean1: processedTags.boolean1 ?? null, + boolean2: processedTags.boolean2 ?? null, + boolean3: processedTags.boolean3 ?? null, } - if (documentRecords.length > 0) { - await tx.insert(document).values(documentRecords) - logger.info( - `[${requestId}] Bulk created ${documentRecords.length} document records in knowledge base ${knowledgeBaseId}` - ) + documentRecords.push(newDocument) + returnData.push({ + documentId, + filename: docData.filename, + fileUrl: docData.fileUrl, + fileSize: docData.fileSize, + mimeType: docData.mimeType, + }) + } - await tx - .update(knowledgeBase) - .set({ updatedAt: now }) - .where(eq(knowledgeBase.id, knowledgeBaseId)) - } + if (documentRecords.length > 0) { + await db.insert(document).values(documentRecords) + logger.info( + `[${requestId}] Bulk created ${documentRecords.length} document records in knowledge base ${knowledgeBaseId}` + ) - return returnData - }) + await db + .update(knowledgeBase) + .set({ updatedAt: now }) + .where(eq(knowledgeBase.id, knowledgeBaseId)) + } + + return returnData } export interface TagFilterCondition { @@ -1312,26 +1312,23 @@ export async function createSingleDocument( ...processedTags, } - await db.transaction(async (tx) => { - await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`) - - const kb = await tx - .select({ id: knowledgeBase.id }) - .from(knowledgeBase) - .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) - .limit(1) + // No tx wrapper: single insert is atomic; KB FK fails loud on concurrent delete. + const kb = await db + .select({ id: knowledgeBase.id }) + .from(knowledgeBase) + .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) + .limit(1) - if (kb.length === 0) { - throw new Error('Knowledge base not found') - } + if (kb.length === 0) { + throw new Error('Knowledge base not found') + } - await tx.insert(document).values(newDocument) + await db.insert(document).values(newDocument) - await tx - .update(knowledgeBase) - .set({ updatedAt: now }) - .where(eq(knowledgeBase.id, knowledgeBaseId)) - }) + await db + .update(knowledgeBase) + .set({ updatedAt: now }) + .where(eq(knowledgeBase.id, knowledgeBaseId)) logger.info(`[${requestId}] Document created: ${documentId} in knowledge base ${knowledgeBaseId}`) return newDocument as { diff --git a/apps/sim/lib/workspaces/lifecycle.test.ts b/apps/sim/lib/workspaces/lifecycle.test.ts index 070b9c4ff25..d165472830a 100644 --- a/apps/sim/lib/workspaces/lifecycle.test.ts +++ b/apps/sim/lib/workspaces/lifecycle.test.ts @@ -55,6 +55,7 @@ describe('workspace lifecycle', () => { }) const tx = { + execute: vi.fn().mockResolvedValue([]), select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([{ id: 'kb-1' }]), diff --git a/apps/sim/lib/workspaces/lifecycle.ts b/apps/sim/lib/workspaces/lifecycle.ts index b0a2b0d6161..975529d27dc 100644 --- a/apps/sim/lib/workspaces/lifecycle.ts +++ b/apps/sim/lib/workspaces/lifecycle.ts @@ -49,6 +49,13 @@ export async function archiveWorkspace( .where(eq(workflowMcpServer.workspaceId, workspaceId)) await db.transaction(async (tx) => { + // Workspace archival is a rare admin/cleanup operation that touches every + // child table; on large workspaces it can exceed the 30s session default. + // Override per-tx with a generous ceiling — if it ever runs longer than + // this something is genuinely wrong. + await tx.execute(sql`SET LOCAL statement_timeout = '5min'`) + await tx.execute(sql`SET LOCAL lock_timeout = '30s'`) + await tx .update(knowledgeBase) .set({ diff --git a/packages/db/db.ts b/packages/db/db.ts index 6868bbaeeb7..264b37121d4 100644 --- a/packages/db/db.ts +++ b/packages/db/db.ts @@ -13,6 +13,14 @@ const postgresClient = postgres(connectionString, { connect_timeout: 30, max: 30, onnotice: () => {}, + // Server-side guards. lock_timeout cancels a query waiting on a row lock for + // >5s (e.g. another tx holding `SELECT ... FOR UPDATE`). statement_timeout + // cancels any query running >30s. Heavy paths that legitimately need longer + // (table service bulk JSONB rewrites) override per-tx with `SET LOCAL`. + connection: { + lock_timeout: 5_000, + statement_timeout: 30_000, + }, }) export const db = drizzle(postgresClient, { schema }) From 512e8870aad70a2130e165e6ac839316921ed1c8 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 14 May 2026 09:24:36 -0700 Subject: [PATCH 3/3] fix(knowledge): close soft-delete TOCTOU on KB document insert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix the race the bots flagged: KB delete is soft (`deletedAt = now`) so the FK can't catch a concurrent KB delete between the existence check and the document insert. - Add `insertDocumentsIfKbAlive` helper that gates the insert on `EXISTS(SELECT 1 FROM knowledge_base WHERE id=$kb AND deleted_at IS NULL)` in the same statement via INSERT...SELECT...WHERE EXISTS. Atomic at the MVCC snapshot — no transaction, no row lock. - Use jsonb_to_recordset to declare column types once, avoiding per-param casts for nullable columns. - Wire into both `createDocumentRecords` (bulk) and `createSingleDocument`. - Keep the upfront KB existence check as a fast-path early-out for the common case; the atomic insert is the race guard. --- apps/sim/lib/knowledge/documents/service.ts | 152 +++++++++++++++++--- 1 file changed, 134 insertions(+), 18 deletions(-) diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 99ffb6ec821..52577a2334e 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -748,6 +748,126 @@ async function processDocumentsWithTrigger( } } +interface NewDocumentRow { + id: string + knowledgeBaseId: string + filename: string + fileUrl: string + fileSize: number + mimeType: string + chunkCount: number + tokenCount: number + characterCount: number + processingStatus: 'pending' + enabled: boolean + uploadedAt: Date + tag1: string | null + tag2: string | null + tag3: string | null + tag4: string | null + tag5: string | null + tag6: string | null + tag7: string | null + number1: number | null + number2: number | null + number3: number | null + number4: number | null + number5: number | null + date1: Date | null + date2: Date | null + boolean1: boolean | null + boolean2: boolean | null + boolean3: boolean | null +} + +/** + * Insert N document rows IF the parent knowledge base is still alive + * (`deleted_at IS NULL`) at the statement's MVCC snapshot. Returns the + * number of rows actually inserted. + * + * Knowledge bases are soft-deleted, so a normal FK can't catch a concurrent + * delete — the KB row physically remains. We do the existence check and the + * insert in a single statement via INSERT...SELECT...WHERE EXISTS, which + * Postgres evaluates atomically. No transaction or row lock required, no + * race window between check and insert. + * + * Returns 0 if the KB was soft-deleted; caller throws. + */ +async function insertDocumentsIfKbAlive( + rows: NewDocumentRow[], + knowledgeBaseId: string +): Promise { + if (rows.length === 0) return 0 + + // jsonb_to_recordset declares the column types once, so we don't need to + // cast every parameter individually to keep Postgres' type inference happy + // when nullable columns end up all-NULL across the batch. + const jsonRows = rows.map((d) => ({ + id: d.id, + knowledge_base_id: d.knowledgeBaseId, + filename: d.filename, + file_url: d.fileUrl, + file_size: d.fileSize, + mime_type: d.mimeType, + chunk_count: d.chunkCount, + token_count: d.tokenCount, + character_count: d.characterCount, + processing_status: d.processingStatus, + enabled: d.enabled, + uploaded_at: d.uploadedAt.toISOString(), + tag1: d.tag1, + tag2: d.tag2, + tag3: d.tag3, + tag4: d.tag4, + tag5: d.tag5, + tag6: d.tag6, + tag7: d.tag7, + number1: d.number1, + number2: d.number2, + number3: d.number3, + number4: d.number4, + number5: d.number5, + date1: d.date1?.toISOString() ?? null, + date2: d.date2?.toISOString() ?? null, + boolean1: d.boolean1, + boolean2: d.boolean2, + boolean3: d.boolean3, + })) + + const result = await db.execute(sql` + INSERT INTO document ( + id, knowledge_base_id, filename, file_url, file_size, mime_type, + chunk_count, token_count, character_count, processing_status, enabled, uploaded_at, + tag1, tag2, tag3, tag4, tag5, tag6, tag7, + number1, number2, number3, number4, number5, + date1, date2, + boolean1, boolean2, boolean3 + ) + SELECT + id, knowledge_base_id, filename, file_url, file_size, mime_type, + chunk_count, token_count, character_count, processing_status, enabled, uploaded_at, + tag1, tag2, tag3, tag4, tag5, tag6, tag7, + number1, number2, number3, number4, number5, + date1, date2, + boolean1, boolean2, boolean3 + FROM jsonb_to_recordset(${JSON.stringify(jsonRows)}::jsonb) AS x( + id text, knowledge_base_id text, filename text, file_url text, file_size integer, mime_type text, + chunk_count integer, token_count integer, character_count integer, processing_status text, enabled boolean, uploaded_at timestamp, + tag1 text, tag2 text, tag3 text, tag4 text, tag5 text, tag6 text, tag7 text, + number1 double precision, number2 double precision, number3 double precision, number4 double precision, number5 double precision, + date1 timestamp, date2 timestamp, + boolean1 boolean, boolean2 boolean, boolean3 boolean + ) + WHERE EXISTS ( + SELECT 1 FROM knowledge_base + WHERE id = ${knowledgeBaseId} AND deleted_at IS NULL + ) + RETURNING id + `) + + return Array.from(result).length +} + export async function createDocumentRecords( documents: Array<{ filename: string @@ -766,10 +886,10 @@ export async function createDocumentRecords( knowledgeBaseId: string, requestId: string ): Promise { - // No tx wrapper: the bulk `db.insert(...).values([...])` is a single statement - // and atomic by Postgres. The KB FK constraint fails loud if the KB is - // concurrently deleted, so an explicit FOR UPDATE lock is unnecessary and - // doubles per-call pool checkouts. + // Cheap upfront existence check so the common KB-not-found path fails fast + // before we burn CPU on tag processing. The atomic insert below is the + // race-safe guard against a concurrent KB soft-delete in the small window + // between this check and the insert. const kb = await db .select({ id: knowledgeBase.id }) .from(knowledgeBase) @@ -781,7 +901,7 @@ export async function createDocumentRecords( } const now = new Date() - const documentRecords = [] + const documentRecords: NewDocumentRow[] = [] const returnData: DocumentData[] = [] for (const docData of documents) { @@ -847,9 +967,12 @@ export async function createDocumentRecords( } if (documentRecords.length > 0) { - await db.insert(document).values(documentRecords) + const insertedCount = await insertDocumentsIfKbAlive(documentRecords, knowledgeBaseId) + if (insertedCount === 0) { + throw new Error('Knowledge base not found') + } logger.info( - `[${requestId}] Bulk created ${documentRecords.length} document records in knowledge base ${knowledgeBaseId}` + `[${requestId}] Bulk created ${insertedCount} document records in knowledge base ${knowledgeBaseId}` ) await db @@ -1297,7 +1420,7 @@ export async function createSingleDocument( } } - const newDocument = { + const newDocument: NewDocumentRow = { id: documentId, knowledgeBaseId, filename: documentData.filename, @@ -1307,24 +1430,17 @@ export async function createSingleDocument( chunkCount: 0, tokenCount: 0, characterCount: 0, + processingStatus: 'pending', enabled: true, uploadedAt: now, ...processedTags, } - // No tx wrapper: single insert is atomic; KB FK fails loud on concurrent delete. - const kb = await db - .select({ id: knowledgeBase.id }) - .from(knowledgeBase) - .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) - .limit(1) - - if (kb.length === 0) { + const insertedCount = await insertDocumentsIfKbAlive([newDocument], knowledgeBaseId) + if (insertedCount === 0) { throw new Error('Knowledge base not found') } - await db.insert(document).values(newDocument) - await db .update(knowledgeBase) .set({ updatedAt: now })