Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/sim/app/_shell/providers/get-query-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ function makeQueryClient() {
retryOnMount: false,
},
mutations: {
retry: 1,
retry: false,
},
dehydrate: {
shouldDehydrateQuery: (query) =>
Expand Down
5 changes: 5 additions & 0 deletions apps/sim/app/api/knowledge/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import { DuplicateNameError } from '@/lib/core/errors'
import {
deleteKnowledgeBase,
getKnowledgeBaseById,
Expand Down Expand Up @@ -166,6 +167,10 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
throw validationError
}
} catch (error) {
if (error instanceof DuplicateNameError) {
return NextResponse.json({ error: error.message }, { status: 409 })
}

logger.error(`[${requestId}] Error updating knowledge base`, error)
return NextResponse.json({ error: 'Failed to update knowledge base' }, { status: 500 })
}
Expand Down
5 changes: 5 additions & 0 deletions apps/sim/app/api/knowledge/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import { DuplicateNameError } from '@/lib/core/errors'
import {
createKnowledgeBase,
getKnowledgeBases,
Expand Down Expand Up @@ -149,6 +150,10 @@ export async function POST(req: NextRequest) {
throw validationError
}
} catch (error) {
if (error instanceof DuplicateNameError) {
return NextResponse.json({ error: error.message }, { status: 409 })
}

logger.error(`[${requestId}] Error creating knowledge base`, error)
return NextResponse.json({ error: 'Failed to create knowledge base' }, { status: 500 })
}
Expand Down
5 changes: 5 additions & 0 deletions apps/sim/app/api/table/[tableId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { DuplicateNameError } from '@/lib/core/errors'
import { deleteTable, NAME_PATTERN, renameTable, TABLE_LIMITS, type TableSchema } from '@/lib/table'
import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils'

Expand Down Expand Up @@ -136,6 +137,10 @@ export async function PATCH(request: NextRequest, { params }: TableRouteParams)
)
}

if (error instanceof DuplicateNameError) {
return NextResponse.json({ error: error.message }, { status: 409 })
}

logger.error(`[${requestId}] Error renaming table:`, error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to rename table' },
Expand Down
75 changes: 68 additions & 7 deletions apps/sim/components/emcn/components/toast/toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ const AUTO_DISMISS_MS = 0
const EXIT_ANIMATION_MS = 200
const MAX_VISIBLE = 20

const RING_RADIUS = 5.5
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS

type ToastVariant = 'default' | 'success' | 'error'

interface ToastAction {
Expand Down Expand Up @@ -100,7 +103,10 @@ const VARIANT_STYLES: Record<ToastVariant, string> = {

function ToastItem({ toast: t, onDismiss }: { toast: ToastData; onDismiss: (id: string) => void }) {
const [exiting, setExiting] = useState(false)
const [paused, setPaused] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const remainingRef = useRef(t.duration)
const startRef = useRef(0)

const dismiss = useCallback(() => {
setExiting(true)
Expand All @@ -109,13 +115,33 @@ function ToastItem({ toast: t, onDismiss }: { toast: ToastData; onDismiss: (id:

useEffect(() => {
if (t.duration > 0) {
startRef.current = Date.now()
remainingRef.current = t.duration
timerRef.current = setTimeout(dismiss, t.duration)
return () => clearTimeout(timerRef.current)
}
}, [dismiss, t.duration])

const handleMouseEnter = useCallback(() => {
if (t.duration <= 0) return
clearTimeout(timerRef.current)
remainingRef.current -= Date.now() - startRef.current
setPaused(true)
}, [t.duration])

const handleMouseLeave = useCallback(() => {
if (t.duration <= 0) return
setPaused(false)
startRef.current = Date.now()
timerRef.current = setTimeout(dismiss, Math.max(remainingRef.current, 0))
}, [dismiss, t.duration])
Comment thread
TheodoreSpeaks marked this conversation as resolved.

const hasDuration = t.duration > 0

return (
<div
onMouseEnter={hasDuration ? handleMouseEnter : undefined}
onMouseLeave={hasDuration ? handleMouseLeave : undefined}
className={cn(
'pointer-events-auto flex w-[320px] items-start gap-[8px] rounded-[8px] border px-[12px] py-[10px] shadow-md transition-all',
VARIANT_STYLES[t.variant],
Expand All @@ -142,13 +168,48 @@ function ToastItem({ toast: t, onDismiss }: { toast: ToastData; onDismiss: (id:
{t.action.label}
</button>
)}
<button
type='button'
onClick={dismiss}
className='shrink-0 rounded-[4px] p-[2px] opacity-60 hover:opacity-100'
>
<X className='h-[14px] w-[14px]' />
</button>
<div className='flex shrink-0 items-center gap-[4px]'>
Comment thread
TheodoreSpeaks marked this conversation as resolved.
Outdated
{hasDuration && (
<svg
width='14'
height='14'
viewBox='0 0 16 16'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className='opacity-60'
style={{ transform: 'rotate(-90deg) scaleX(-1)' }}
>
<circle
cx='8'
cy='8'
r={RING_RADIUS}
stroke='currentColor'
strokeWidth='1.5'
opacity={0.2}
/>
<circle
cx='8'
cy='8'
r={RING_RADIUS}
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeDasharray={RING_CIRCUMFERENCE}
style={{
animation: `notification-countdown ${t.duration}ms linear forwards`,
animationPlayState: paused ? 'paused' : 'running',
}}
/>
</svg>
)}
<button
type='button'
onClick={dismiss}
className='shrink-0 rounded-[4px] p-[2px] opacity-60 hover:opacity-100'
>
<X className='h-[14px] w-[14px]' />
</button>
</div>
</div>
)
}
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/hooks/queries/kb/knowledge.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from '@/components/emcn'
import type {
ChunkData,
ChunksPagination,
Expand Down Expand Up @@ -773,6 +774,9 @@ export function useUpdateKnowledgeBase(workspaceId?: string) {

return useMutation({
mutationFn: updateKnowledgeBase,
onError: (error) => {
toast.error(error.message, { duration: 5000 })
},
onSuccess: (_, { knowledgeBaseId }) => {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/hooks/queries/tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import { createLogger } from '@sim/logger'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from '@/components/emcn'
import type { Filter, RowData, Sort, TableDefinition, TableMetadata, TableRow } from '@/lib/table'

const logger = createLogger('TableQueries')
Expand Down Expand Up @@ -308,6 +309,9 @@ export function useRenameTable(workspaceId: string) {

return res.json()
},
onError: (error) => {
toast.error(error.message, { duration: 5000 })
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: tableKeys.detail(variables.tableId) })
queryClient.invalidateQueries({ queryKey: tableKeys.lists() })
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/hooks/queries/workspace-files.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from '@/components/emcn'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'

const logger = createLogger('WorkspaceFilesQuery')
Expand Down Expand Up @@ -245,6 +246,9 @@ export function useRenameWorkspaceFile() {

return data
},
onError: (error) => {
toast.error(error.message, { duration: 5000 })
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.lists() })
},
Expand Down
9 changes: 9 additions & 0 deletions apps/sim/lib/core/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Thrown when a create/rename operation would violate a workspace-scoped
* unique name constraint (e.g. tables, knowledge bases, files).
*/
export class DuplicateNameError extends Error {
constructor(entity: string, name: string) {
super(`A ${entity} named "${name}" already exists in this workspace`)
}
}
35 changes: 35 additions & 0 deletions apps/sim/lib/core/utils/restore-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { randomBytes } from 'crypto'

/**
* Generates a unique name for a restored entity by trying in order:
* 1. The original name
* 2. `name_restored` (inserted before file extension when `hasExtension` is true)
* 3. `name_restored_{6-char hex}` (practically guaranteed unique)
*/
export async function generateRestoreName(
originalName: string,
nameExists: (name: string) => Promise<boolean>,
options?: { hasExtension?: boolean }
): Promise<string> {
if (!(await nameExists(originalName))) {
return originalName
}

const restoredName = addSuffix(originalName, '_restored', options?.hasExtension)
if (!(await nameExists(restoredName))) {
return restoredName
}

const hash = randomBytes(3).toString('hex')
return addSuffix(originalName, `_restored_${hash}`, options?.hasExtension)
}
Comment thread
TheodoreSpeaks marked this conversation as resolved.

function addSuffix(name: string, suffix: string, hasExtension?: boolean): string {
if (hasExtension) {
const dotIndex = name.lastIndexOf('.')
if (dotIndex > 0) {
return `${name.slice(0, dotIndex)}${suffix}${name.slice(dotIndex)}`
}
}
return `${name}${suffix}`
}
68 changes: 65 additions & 3 deletions apps/sim/lib/knowledge/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { randomUUID } from 'crypto'
import { db } from '@sim/db'
import { document, knowledgeBase, knowledgeConnector, permissions, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, count, eq, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm'
import { and, count, eq, inArray, isNotNull, isNull, ne, or, sql } from 'drizzle-orm'
import { DuplicateNameError } from '@/lib/core/errors'
import { generateRestoreName } from '@/lib/core/utils/restore-name'
import type {
ChunkingConfig,
CreateKnowledgeBaseData,
Expand Down Expand Up @@ -157,6 +159,22 @@ export async function createKnowledgeBase(
deletedAt: null,
}

const duplicate = await db
.select({ id: knowledgeBase.id })
.from(knowledgeBase)
.where(
and(
eq(knowledgeBase.workspaceId, data.workspaceId),
eq(knowledgeBase.name, data.name),
isNull(knowledgeBase.deletedAt)
)
)
.limit(1)

if (duplicate.length > 0) {
throw new DuplicateNameError('knowledge base', data.name)
}

Comment thread
cursor[bot] marked this conversation as resolved.
await db.insert(knowledgeBase).values(newKnowledgeBase)

logger.info(`[${requestId}] Created knowledge base: ${data.name} (${kbId})`)
Comment thread
TheodoreSpeaks marked this conversation as resolved.
Expand Down Expand Up @@ -222,6 +240,33 @@ export async function updateKnowledgeBase(
updateData.embeddingDimension = 1536
}

if (updates.name !== undefined) {
const existing = await db
.select({ id: knowledgeBase.id, workspaceId: knowledgeBase.workspaceId })
.from(knowledgeBase)
.where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt)))
.limit(1)

if (existing.length > 0 && existing[0].workspaceId) {
const duplicate = await db
.select({ id: knowledgeBase.id })
.from(knowledgeBase)
.where(
and(
eq(knowledgeBase.workspaceId, existing[0].workspaceId),
eq(knowledgeBase.name, updates.name),
isNull(knowledgeBase.deletedAt),
ne(knowledgeBase.id, knowledgeBaseId)
)
)
.limit(1)

if (duplicate.length > 0) {
throw new DuplicateNameError('knowledge base', updates.name)
}
}
}

await db
.update(knowledgeBase)
.set(updateData)
Expand Down Expand Up @@ -383,6 +428,7 @@ export async function restoreKnowledgeBase(
const [kb] = await db
.select({
id: knowledgeBase.id,
name: knowledgeBase.name,
deletedAt: knowledgeBase.deletedAt,
workspaceId: knowledgeBase.workspaceId,
})
Expand All @@ -406,14 +452,30 @@ export async function restoreKnowledgeBase(
}
}

const newName = await generateRestoreName(kb.name, async (candidate) => {
if (!kb.workspaceId) return false
const [match] = await db
.select({ id: knowledgeBase.id })
.from(knowledgeBase)
.where(
and(
eq(knowledgeBase.workspaceId, kb.workspaceId),
eq(knowledgeBase.name, candidate),
isNull(knowledgeBase.deletedAt)
)
)
.limit(1)
return !!match
})

const now = new Date()

await db.transaction(async (tx) => {
await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`)

await tx
.update(knowledgeBase)
.set({ deletedAt: null, updatedAt: now })
.set({ deletedAt: null, updatedAt: now, name: newName })
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
.where(eq(knowledgeBase.id, knowledgeBaseId))

await tx
Expand All @@ -439,5 +501,5 @@ export async function restoreKnowledgeBase(
)
})

logger.info(`[${requestId}] Restored knowledge base: ${knowledgeBaseId}`)
logger.info(`[${requestId}] Restored knowledge base: ${knowledgeBaseId} as "${newName}"`)
}
Loading
Loading