Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 3.8k
v0.6.2: mothership stability, chat iframe embedding, KB upserts, new blog post#3650
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
cdd0f7575a3e2cc9f082d67478bb2bc11a75f89c71168cd5828de288b84f30e8a4c16160bb942File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Uh oh!
There was an error while loading. Please reload this page.
Large diffs are not rendered by default.
Uh oh!
There was an error while loading. Please reload this page.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,248 @@ | ||
| import { randomUUID } from 'crypto' | ||
| import { db } from '@sim/db' | ||
| import { document } from '@sim/db/schema' | ||
| import { createLogger } from '@sim/logger' | ||
| import { and, eq, isNull } from 'drizzle-orm' | ||
| import { type NextRequest, NextResponse } from 'next/server' | ||
| import { z } from 'zod' | ||
| import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' | ||
| import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' | ||
| import { | ||
| createDocumentRecords, | ||
| deleteDocument, | ||
| getProcessingConfig, | ||
| processDocumentsWithQueue, | ||
| } from '@/lib/knowledge/documents/service' | ||
| import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' | ||
| import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' | ||
| const logger = createLogger('DocumentUpsertAPI') | ||
| const UpsertDocumentSchema = z.object({ | ||
| documentId: z.string().optional(), | ||
| filename: z.string().min(1, 'Filename is required'), | ||
| fileUrl: z.string().min(1, 'File URL is required'), | ||
| fileSize: z.number().min(1, 'File size must be greater than 0'), | ||
| mimeType: z.string().min(1, 'MIME type is required'), | ||
| documentTagsData: z.string().optional(), | ||
| processingOptions: z.object({ | ||
| chunkSize: z.number().min(100).max(4000), | ||
| minCharactersPerChunk: z.number().min(1).max(2000), | ||
| recipe: z.string(), | ||
| lang: z.string(), | ||
| chunkOverlap: z.number().min(0).max(500), | ||
| }), | ||
| workflowId: z.string().optional(), | ||
| }) | ||
| export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { | ||
| const requestId = randomUUID().slice(0, 8) | ||
| const { id: knowledgeBaseId } = await params | ||
| try { | ||
| const body = await req.json() | ||
| logger.info(`[${requestId}] Knowledge base document upsert request`, { | ||
| knowledgeBaseId, | ||
| hasDocumentId: !!body.documentId, | ||
| filename: body.filename, | ||
| }) | ||
| const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) | ||
| if (!auth.success || !auth.userId) { | ||
| logger.warn(`[${requestId}] Authentication failed: ${auth.error || 'Unauthorized'}`) | ||
| return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) | ||
| } | ||
| const userId = auth.userId | ||
| const validatedData = UpsertDocumentSchema.parse(body) | ||
| if (validatedData.workflowId) { | ||
| const authorization = await authorizeWorkflowByWorkspacePermission({ | ||
| workflowId: validatedData.workflowId, | ||
| userId, | ||
| action: 'write', | ||
| }) | ||
| if (!authorization.allowed) { | ||
| return NextResponse.json( | ||
| { error: authorization.message || 'Access denied' }, | ||
| { status: authorization.status } | ||
| ) | ||
| } | ||
| } | ||
| const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, userId) | ||
| if (!accessCheck.hasAccess) { | ||
| if ('notFound' in accessCheck && accessCheck.notFound) { | ||
| logger.warn(`[${requestId}] Knowledge base not found: ${knowledgeBaseId}`) | ||
| return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) | ||
| } | ||
| logger.warn( | ||
| `[${requestId}] User ${userId} attempted to upsert document in unauthorized knowledge base ${knowledgeBaseId}` | ||
| ) | ||
| return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) | ||
| } | ||
| let existingDocumentId: string | null = null | ||
| let isUpdate = false | ||
| if (validatedData.documentId) { | ||
| const existingDoc = await db | ||
| .select({ id: document.id }) | ||
| .from(document) | ||
| .where( | ||
| and( | ||
| eq(document.id, validatedData.documentId), | ||
| eq(document.knowledgeBaseId, knowledgeBaseId), | ||
| isNull(document.deletedAt) | ||
| ) | ||
| ) | ||
| .limit(1) | ||
| if (existingDoc.length > 0) { | ||
| existingDocumentId = existingDoc[0].id | ||
| } | ||
| } else { | ||
| const docsByFilename = await db | ||
| .select({ id: document.id }) | ||
| .from(document) | ||
| .where( | ||
| and( | ||
| eq(document.filename, validatedData.filename), | ||
| eq(document.knowledgeBaseId, knowledgeBaseId), | ||
| isNull(document.deletedAt) | ||
| ) | ||
| ) | ||
| .limit(1) | ||
| if (docsByFilename.length > 0) { | ||
| existingDocumentId = docsByFilename[0].id | ||
| } | ||
| } | ||
| if (existingDocumentId) { | ||
| isUpdate = true | ||
| logger.info( | ||
| `[${requestId}] Found existing document ${existingDocumentId}, creating replacement before deleting old` | ||
| ) | ||
| } | ||
| const createdDocuments = await createDocumentRecords( | ||
| [ | ||
| { | ||
| filename: validatedData.filename, | ||
| fileUrl: validatedData.fileUrl, | ||
| fileSize: validatedData.fileSize, | ||
| mimeType: validatedData.mimeType, | ||
| ...(validatedData.documentTagsData && { | ||
| documentTagsData: validatedData.documentTagsData, | ||
| }), | ||
| }, | ||
| ], | ||
| knowledgeBaseId, | ||
| requestId | ||
| ) | ||
| const firstDocument = createdDocuments[0] | ||
| if (!firstDocument) { | ||
| logger.error(`[${requestId}] createDocumentRecords returned empty array unexpectedly`) | ||
| return NextResponse.json({ error: 'Failed to create document record' }, { status: 500 }) | ||
| } | ||
| if (existingDocumentId) { | ||
| try { | ||
| await deleteDocument(existingDocumentId, requestId) | ||
| } catch (deleteError) { | ||
| logger.error( | ||
| `[${requestId}] Failed to delete old document ${existingDocumentId}, rolling back new record`, | ||
| deleteError | ||
| ) | ||
| await deleteDocument(firstDocument.documentId, requestId).catch(() => {}) | ||
| return NextResponse.json({ error: 'Failed to replace existing document' }, { status: 500 }) | ||
| } | ||
| } | ||
| processDocumentsWithQueue( | ||
| createdDocuments, | ||
| knowledgeBaseId, | ||
| validatedData.processingOptions, | ||
| requestId | ||
| ).catch((error: unknown) => { | ||
| logger.error(`[${requestId}] Critical error in document processing pipeline:`, error) | ||
| }) | ||
| try { | ||
| const { PlatformEvents } = await import('@/lib/core/telemetry') | ||
| PlatformEvents.knowledgeBaseDocumentsUploaded({ | ||
| knowledgeBaseId, | ||
| documentsCount: 1, | ||
| uploadType: 'single', | ||
| chunkSize: validatedData.processingOptions.chunkSize, | ||
| recipe: validatedData.processingOptions.recipe, | ||
| }) | ||
| } catch (_e) { | ||
| // Silently fail | ||
| } | ||
| recordAudit({ | ||
| workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, | ||
| actorId: userId, | ||
| actorName: auth.userName, | ||
| actorEmail: auth.userEmail, | ||
| action: isUpdate ? AuditAction.DOCUMENT_UPDATED : AuditAction.DOCUMENT_UPLOADED, | ||
| resourceType: AuditResourceType.DOCUMENT, | ||
| resourceId: knowledgeBaseId, | ||
| resourceName: validatedData.filename, | ||
| description: isUpdate | ||
| ? `Upserted (replaced) document "${validatedData.filename}" in knowledge base "${knowledgeBaseId}"` | ||
| : `Upserted (created) document "${validatedData.filename}" in knowledge base "${knowledgeBaseId}"`, | ||
| metadata: { | ||
| fileName: validatedData.filename, | ||
| previousDocumentId: existingDocumentId, | ||
| isUpdate, | ||
| }, | ||
| request: req, | ||
| }) | ||
| return NextResponse.json({ | ||
| success: true, | ||
| data: { | ||
| documentsCreated: [ | ||
| { | ||
| documentId: firstDocument.documentId, | ||
| filename: firstDocument.filename, | ||
| status: 'pending', | ||
| }, | ||
| ], | ||
| isUpdate, | ||
| previousDocumentId: existingDocumentId, | ||
| processingMethod: 'background', | ||
| processingConfig: { | ||
| maxConcurrentDocuments: getProcessingConfig().maxConcurrentDocuments, | ||
| batchSize: getProcessingConfig().batchSize, | ||
| }, | ||
| }, | ||
| }) | ||
| } catch (error) { | ||
| if (error instanceof z.ZodError) { | ||
| logger.warn(`[${requestId}] Invalid upsert request data`, { errors: error.errors }) | ||
| return NextResponse.json( | ||
| { error: 'Invalid request data', details: error.errors }, | ||
| { status: 400 } | ||
| ) | ||
| } | ||
| logger.error(`[${requestId}] Error upserting document`, error) | ||
| const errorMessage = error instanceof Error ? error.message : 'Failed to upsert document' | ||
| const isStorageLimitError = | ||
| errorMessage.includes('Storage limit exceeded') || errorMessage.includes('storage limit') | ||
| const isMissingKnowledgeBase = errorMessage === 'Knowledge base not found' | ||
| return NextResponse.json( | ||
| { error: errorMessage }, | ||
| { status: isMissingKnowledgeBase ? 404 : isStorageLimitError ? 413 : 500 } | ||
| ) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { MessageActions } from './message-actions' |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The create-then-delete rollback silently swallows errors:
If the rollback itself fails (e.g. transient DB error), both the old document and the newly created document record will exist in the knowledge base simultaneously. The caller receives a 500, but neither record is cleaned up, leading to duplicate documents that are invisible to normal user flows but still consume storage and can surface in search results.
Since the whole operation is logically atomic (replace), wrapping
createDocumentRecordsanddeleteDocumentin a database transaction would be the safest fix. If a transaction isn't feasible here (e.g. the service layer doesn't expose transaction contexts), at minimum the rollback failure should be logged aterrorlevel with enough context to trigger manual cleanup: