Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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/api/knowledge/search/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({ search: vi.fn() }))
vi.mock('@/lib/knowledge/application/workspace-search', () => ({
vi.mock('@/lib/sim-search/indexed', () => ({
searchScopedKnowledge: { operation: { id: 'knowledge.search' }, execute: mocks.search },
}))

Expand Down
7 changes: 4 additions & 3 deletions apps/sim/app/api/knowledge/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import {
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { isLiveEnterpriseSearchEnabled } from '@/lib/core/config/env-flags'
import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies'
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
import { searchScopedKnowledge } from '@/lib/knowledge/application/workspace-search'
import { DEFAULT_RERANKER_MODEL } from '@/lib/knowledge/reranker-models'
import { sourceAuthor } from '@/lib/knowledge/search/author'
import { searchScopedKnowledge } from '@/lib/sim-search/indexed'
import { isIndexedOrgSearchEnabled } from '@/lib/sim-search/indexed/gate'
import { searchLiveKnowledge } from '@/lib/sim-search/live/application'

const DIRECT_SEARCH_VECTOR_BUDGET_MS = 3000
Expand Down Expand Up @@ -81,4 +81,5 @@ const liveSearchRoute = defineInternalJsonRoute({
present: (data) => ({ success: true as const, data }),
})

export const POST = isLiveEnterpriseSearchEnabled ? liveSearchRoute : indexedSearchRoute
/** Indexed organization search is dormant unless its gate is on; Live Search serves otherwise. */
export const POST = isIndexedOrgSearchEnabled() ? indexedSearchRoute : liveSearchRoute
62 changes: 60 additions & 2 deletions apps/sim/app/api/v1/knowledge/search/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@
* @vitest-environment node
*/

import { createMockRequest, knowledgeApiUtilsMock, knowledgeApiUtilsMockFns } from '@sim/testing'
import {
createMockRequest,
knowledgeApiUtilsMock,
knowledgeApiUtilsMockFns,
resetEnvFlagsMock,
setEnvFlags,
} from '@sim/testing'
import { getErrorMessage } from '@sim/utils/errors'
import { beforeEach, describe, expect, it, vi } from 'vitest'

Expand Down Expand Up @@ -71,8 +77,12 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({
resolveBillingAttribution: mockResolveBillingAttribution,
resolveSystemBillingAttribution: mockResolveSystemBillingAttribution,
}))
const { mockCheckSearchUsageLimits } = vi.hoisted(() => ({
mockCheckSearchUsageLimits: vi.fn(),
}))

vi.mock('@/lib/billing/core/usage-gate-cache', () => ({
checkSearchUsageLimits: vi.fn().mockResolvedValue({ isExceeded: false }),
checkSearchUsageLimits: mockCheckSearchUsageLimits,
}))

vi.mock('@/lib/knowledge/embeddings', () => ({
Expand Down Expand Up @@ -118,6 +128,8 @@ const baseKb = (id: string, embeddingModel: string, embeddingDimension = 1536) =
describe('v1 knowledge search route — per-KB embedding model', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCheckSearchUsageLimits.mockResolvedValue({ isExceeded: false })
resetEnvFlagsMock()
mockResolveV1KnowledgeReadAccess.mockResolvedValue({ kind: 'workspace', tokens: ['pub', 'ws'] })
mockAuthenticateRequest.mockResolvedValue({
requestId: 'req-1',
Expand Down Expand Up @@ -163,6 +175,52 @@ describe('v1 knowledge search route — per-KB embedding model', () => {
expect(response.status).toBe(500)
})

it('refuses a search index while indexed organization search is dormant, before any spend', async () => {
setEnvFlags({ isLiveEnterpriseSearchEnabled: true })
mockCheckKnowledgeBaseAccess.mockResolvedValueOnce({
hasAccess: true,
knowledgeBase: { ...baseKb('kb-1', 'text-embedding-3-small'), isSearchIndex: true },
})
const response = await POST(
createMockRequest('POST', { workspaceId: 'ws-1', knowledgeBaseIds: 'kb-1', query: 'hello' })
)
expect(response.status).toBe(409)
expect(await response.json()).toEqual({
error: 'This search index is inactive; use Sim Search.',
})
expect(mockGenerateSearchEmbedding).not.toHaveBeenCalled()
expect(mockExecuteKnowledgeSearch).not.toHaveBeenCalled()
})

it('refuses a dormant search index for its dormancy, not the caller exhausted usage', async () => {
setEnvFlags({ isLiveEnterpriseSearchEnabled: true })
mockCheckSearchUsageLimits.mockResolvedValue({ isExceeded: true, message: 'Over limit' })
mockCheckKnowledgeBaseAccess.mockResolvedValueOnce({
hasAccess: true,
knowledgeBase: { ...baseKb('kb-1', 'text-embedding-3-small'), isSearchIndex: true },
})
const response = await POST(
createMockRequest('POST', { workspaceId: 'ws-1', knowledgeBaseIds: 'kb-1', query: 'hello' })
)
expect(response.status).toBe(409)
expect(mockCheckSearchUsageLimits).not.toHaveBeenCalled()
})

it('searches a workspace knowledge base while indexed organization search is dormant', async () => {
setEnvFlags({ isLiveEnterpriseSearchEnabled: true })
mockCheckKnowledgeBaseAccess.mockResolvedValueOnce({
hasAccess: true,
knowledgeBase: { ...baseKb('kb-1', 'text-embedding-3-small'), isSearchIndex: false },
})
const response = await POST(
createMockRequest('POST', { workspaceId: 'ws-1', knowledgeBaseIds: 'kb-1', query: 'hello' })
)
expect(response.status).toBe(200)
expect(mockExecuteKnowledgeSearch).toHaveBeenCalledWith(
expect.objectContaining({ knowledgeBaseIds: ['kb-1'], searchIndexOnly: false })
)
})

it('retains the reader provider for ranked results and returned document metadata', async () => {
const access = { kind: 'user' as const, userId: 'user-1', tokens: ['reader-token'] }
const provider = {
Expand Down
39 changes: 25 additions & 14 deletions apps/sim/app/api/v1/knowledge/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ import {
import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service'
import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils'
import type { StructuredFilter } from '@/lib/knowledge/types'
import {
isIndexedOrgSearchEnabled,
SEARCH_INDEX_DORMANT_MESSAGE,
} from '@/lib/sim-search/indexed/gate'
import { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils'
import { handleError, resolveV1KnowledgeReadAccess } from '@/app/api/v1/knowledge/utils'
import {
Expand Down Expand Up @@ -77,20 +81,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
: undefined
const billingActorUserId = billingAttribution?.actorUserId ?? userId

/**
* Query embeddings incur hosted cost; tag-only searches do not. Workspace
* keys resolve their system actor and immutable payer from one workspace read.
*/
if (billingAttribution) {
const usage = await checkSearchUsageLimits(billingAttribution)
if (usage.isExceeded) {
return NextResponse.json(
{ error: usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' },
{ status: 402 }
)
}
}

const knowledgeBaseIds = Array.isArray(parsed.data.body.knowledgeBaseIds)
? parsed.data.body.knowledgeBaseIds
: [parsed.data.body.knowledgeBaseIds]
Expand Down Expand Up @@ -121,6 +111,27 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
)
}

/** A search index is readable only while indexed organization search is on. */
if (!isIndexedOrgSearchEnabled() && accessibleKbs.some((kb) => kb.isSearchIndex)) {
Comment thread
waleedlatif1 marked this conversation as resolved.
return NextResponse.json({ error: SEARCH_INDEX_DORMANT_MESSAGE }, { status: 409 })
}

/**
* Query embeddings incur hosted cost; tag-only searches do not. Workspace
* keys resolve their system actor and immutable payer from one workspace read.
* Admission follows the access and dormancy checks, so a request that could
* never run is refused for that reason rather than for the caller's usage.
*/
if (billingAttribution) {
const usage = await checkSearchUsageLimits(billingAttribution)
if (usage.isExceeded) {
return NextResponse.json(
{ error: usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' },
{ status: 402 }
)
}
}

let structuredFilters: StructuredFilter[] = []
const tagDefsCache = new Map<string, Awaited<ReturnType<typeof getDocumentTagDefinitions>>>()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import type { SearchParams } from 'nuqs/server'
import { readSearchDocumentResultSchema } from '@/lib/api/contracts/knowledge/documents'
import { getSession } from '@/lib/auth'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { readSearchDocument } from '@/lib/knowledge/application/read-search-document'
import { readSearchDocument } from '@/lib/sim-search/indexed'
import { isIndexedOrgSearchEnabled } from '@/lib/sim-search/indexed/gate'
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
import {
loadDocumentReadParams,
Expand All @@ -22,6 +23,8 @@ export default async function OrganizationDocumentPage({
params,
searchParams,
}: OrganizationDocumentPageProps) {
/** Indexed documents are served only while indexed organization search is on. */
if (!isIndexedOrgSearchEnabled()) notFound()
const { organizationId, knowledgeBaseId, documentId } = await params
const position = await loadDocumentReadParams(searchParams, { strict: true }).catch(() =>
notFound()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ import { eq, inArray } from 'drizzle-orm'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'

const provider = vi.hoisted(() => ({ list: vi.fn(), get: vi.fn(), changes: vi.fn() }))
/** This suite covers indexed organization search, which is dormant unless Live Search is off. */
vi.mock('@/lib/core/config/env-flags', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/core/config/env-flags')>()),
isLiveEnterpriseSearchEnabled: false,
}))
vi.mock('@/connectors/registry.server', () => ({
CONNECTOR_REGISTRY: {
google_drive: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ import { generateId } from '@sim/utils/id'
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

/** This suite covers indexed organization search, which is dormant unless Live Search is off. */
vi.mock('@/lib/core/config/env-flags', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/core/config/env-flags')>()),
isLiveEnterpriseSearchEnabled: false,
}))
vi.mock('@/lib/embeddings', async () => ({
...(await import('@/lib/embeddings/client')),
assertKnowledgeEmbeddingCapacity: async () => {},
Expand Down Expand Up @@ -83,7 +88,6 @@ import {
listKnowledgeConnectorDocuments,
} from '@/lib/knowledge/application/connectors'
import { readKnowledgeDocument } from '@/lib/knowledge/application/documents'
import { readIndexedKnowledgeDocument } from '@/lib/knowledge/application/read-indexed-document'
import { searchKnowledge } from '@/lib/knowledge/application/search'
import { readSearchSourceOverview } from '@/lib/knowledge/application/search-source-overview'
import { listSearchSources } from '@/lib/knowledge/application/search-sources'
Expand All @@ -95,6 +99,7 @@ import {
} from '@/lib/knowledge/connectors/sync-limits'
import { getDocuments } from '@/lib/knowledge/documents/service'
import { getTagUsageStats } from '@/lib/knowledge/tags/service'
import { readIndexedKnowledgeDocument } from '@/lib/sim-search/indexed/documents/read-indexed-document'
import { deleteFile } from '@/lib/uploads/core/storage-service'
import { downloadFileFromUrl } from '@/lib/uploads/utils/file-utils.server'
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,9 @@ import { listKnowledgeChunks } from '@/lib/knowledge/application/chunks'
import { updateKnowledgeConnectorAccess } from '@/lib/knowledge/application/connector-access'
import { updateKnowledgeConnector } from '@/lib/knowledge/application/connectors'
import { readKnowledgeDocument } from '@/lib/knowledge/application/documents'
import { readSearchDocument } from '@/lib/knowledge/application/read-search-document'
import { searchKnowledge } from '@/lib/knowledge/application/search'
import { executeSync } from '@/lib/knowledge/connectors/sync-engine'
import { readSearchDocument } from '@/lib/sim-search/indexed/documents/read-search-document'
import * as storage from '@/lib/uploads/core/storage-service'
import { downloadFileFromUrl } from '@/lib/uploads/utils/file-utils.server'
import { PATCH as updateConnectorRoute } from '@/app/api/knowledge/[id]/connectors/[connectorId]/route'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ import { eq, inArray } from 'drizzle-orm'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'

const counters = vi.hoisted(() => ({ embeddedTexts: 0 }))
/** This suite covers indexed organization search, which is dormant unless Live Search is off. */
vi.mock('@/lib/core/config/env-flags', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/core/config/env-flags')>()),
isLiveEnterpriseSearchEnabled: false,
}))
vi.mock('@/lib/embeddings', async () => ({
...(await import('@/lib/embeddings/client')),
assertKnowledgeEmbeddingCapacity: async () => {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ import { generateId } from '@sim/utils/id'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'

/** This suite covers indexed organization search, which is dormant unless Live Search is off. */
vi.mock('@/lib/core/config/env-flags', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/core/config/env-flags')>()),
isLiveEnterpriseSearchEnabled: false,
}))
vi.mock('@/lib/embeddings', async () => ({
...(await import('@/lib/embeddings/client')),
assertKnowledgeEmbeddingCapacity: async () => {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ import { generateId } from '@sim/utils/id'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'

/** This suite covers indexed organization search, which is dormant unless Live Search is off. */
vi.mock('@/lib/core/config/env-flags', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/core/config/env-flags')>()),
isLiveEnterpriseSearchEnabled: false,
}))
vi.mock('@/lib/embeddings', async () => ({
...(await import('@/lib/embeddings/client')),
assertKnowledgeEmbeddingCapacity: async () => {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ import {
} from '@/lib/knowledge/__integration__/seed-source-access-fixture'
import { createKnowledgeAccessProvider } from '@/lib/knowledge/access/scope'
import {
forgetProjectionFilled,
resolvePermittedDocuments,
retrieveKnowledgeSearch,
VECTOR_PROBE_DOCUMENT_LIMIT,
} from '@/lib/knowledge/search/queries'
import { embeddingVectorValues } from '@/lib/knowledge/vector-columns'
import { forgetProjectionFilled } from '@/lib/sim-search/indexed/retrieval'

describe('API-key KB block fan-out', () => {
const ids = createKnowledgeAclFixtureIds()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,13 @@ import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
import postgres from 'postgres'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'

/** This suite covers indexed organization search, which is dormant unless Live Search is off. */
vi.mock('@/lib/core/config/env-flags', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/core/config/env-flags')>()),
isLiveEnterpriseSearchEnabled: false,
}))
/** The TINQL `resolveTinKeywordQuery` renders for `fixture`: its `english` stem, quoted. */
vi.mock('@/lib/knowledge/search/tin-keyword', () => ({
vi.mock('@/lib/sim-search/indexed/retrieval/tin-keyword', () => ({
resolveTinKeywordQuery: async () => '"fixtur"',
}))

Expand Down Expand Up @@ -76,11 +81,11 @@ import type {
import { leaseTransaction } from '@/lib/knowledge/connectors/sync-lock'
import {
executeKeywordSearch,
forgetProjectionFilled,
handleVectorOnlySearch,
liveSourceAccessFor,
} from '@/lib/knowledge/search/queries'
import { GITHUB_INSTALLATION_PROVIDER_ID } from '@/lib/oauth/github-installation-types'
import { forgetProjectionFilled } from '@/lib/sim-search/indexed/retrieval'

const ids = createKnowledgeAclFixtureIds()
const connectorId = generateId()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,14 @@ import { confluencePageAcl } from '@/lib/knowledge/access/confluence-permissions
import { listKnowledgeChunks } from '@/lib/knowledge/application/chunks'
import { readKnowledgeDocument } from '@/lib/knowledge/application/documents'
import { listKnowledgeBaseCatalog } from '@/lib/knowledge/application/knowledge-bases'
import { readIndexedKnowledgeDocument } from '@/lib/knowledge/application/read-indexed-document'
import { prepareSearchSource } from '@/lib/knowledge/application/sim-search'
import { searchScopedKnowledge } from '@/lib/knowledge/application/workspace-search'
import { createContentSyncLease } from '@/lib/knowledge/connectors/sync-lock'
import { addDocument, persistDocumentAcls } from '@/lib/knowledge/connectors/sync-persistence'
import { processDocumentAsync } from '@/lib/knowledge/documents/service'
import { getSearchMcpUrl } from '@/lib/knowledge/mcp/urls'
import { replaceKnowledgeEmbeddingSecretProvenanceInTx } from '@/lib/knowledge/secret-provenance'
import { readIndexedKnowledgeDocument } from '@/lib/sim-search/indexed/documents/read-indexed-document'
import { searchScopedKnowledge } from '@/lib/sim-search/indexed/search/scoped-search'
import { DELETE, GET, POST } from '@/app/api/mcp/search/organizations/[organizationId]/route'
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ import { and, eq, inArray, sql } from 'drizzle-orm'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'

const fixtureStorage = vi.hoisted(() => ({ root: '' }))
/** This suite covers indexed organization search, which is dormant unless Live Search is off. */
vi.mock('@/lib/core/config/env-flags', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/core/config/env-flags')>()),
isLiveEnterpriseSearchEnabled: false,
}))
vi.mock('@/lib/uploads/core/setup.server', () => ({
get UPLOAD_DIR_SERVER() {
return fixtureStorage.root
Expand All @@ -49,7 +54,6 @@ import {
seedKnowledgeMemberFixture,
} from '@/lib/knowledge/__integration__/seed-source-access-fixture'
import { searchKnowledge } from '@/lib/knowledge/application/search'
import { searchScopedKnowledge } from '@/lib/knowledge/application/workspace-search'
import {
materializeDocumentAcls,
recordMemberObservations,
Expand All @@ -61,6 +65,7 @@ import { knowledgeDocumentProcessingOutboxHandlers } from '@/lib/knowledge/docum
import { assertDocumentProcessingPayload } from '@/lib/knowledge/documents/processing-payload'
import * as providerContinuation from '@/lib/knowledge/documents/processing-provider-continuation'
import { processDocumentsWithQueue } from '@/lib/knowledge/documents/service'
import { searchScopedKnowledge } from '@/lib/sim-search/indexed/search/scoped-search'

const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=',
Expand Down
Loading
Loading