Skip to content

Commit b8c8ca1

Browse files
committed
fix(search): keep private history independent of indexed search
1 parent aa7dac5 commit b8c8ca1

9 files changed

Lines changed: 43 additions & 29656 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-history-context.tsx‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
useMemo,
1010
} from 'react'
1111
import { noop } from '@sim/utils/helpers'
12+
import { truncateAtCodePoint } from '@sim/utils/string'
1213
import type { ViewedSearchSource } from '@/lib/api/contracts/knowledge/search-history'
1314
import { isKnowledgeSourceUrl } from '@/lib/knowledge/search/source-url'
1415
import { handleExternalLinkClick } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-link'
@@ -71,9 +72,18 @@ export function useSourceNavigation(source: SourceTagData) {
7172
return useCallback(
7273
(event: MouseEvent<HTMLAnchorElement>) => {
7374
if (event.defaultPrevented || (event.button !== 0 && event.button !== 1)) return
74-
recordSource({ url: source.url })
75+
recordSource({
76+
url: source.url,
77+
title: source.title?.trim() ? truncateAtCodePoint(source.title.trim(), 512, '') : undefined,
78+
siteName: source.siteName?.trim()
79+
? truncateAtCodePoint(source.siteName.trim(), 128, '')
80+
: undefined,
81+
connectorType: source.connectorType?.trim()
82+
? truncateAtCodePoint(source.connectorType.trim(), 64, '')
83+
: undefined,
84+
})
7585
handleExternalLinkClick(event, source.url)
7686
},
77-
[source.url, recordSource]
87+
[source.url, source.title, source.siteName, source.connectorType, recordSource]
7888
)
7989
}

‎apps/sim/lib/api/contracts/knowledge/search-history.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,10 @@ export const viewedSearchSourceSchema = z.object({
1414
siteName: z.string().trim().min(1).max(128).optional(),
1515
connectorType: z.string().trim().min(1).max(64).optional(),
1616
})
17-
export const recordSearchSourceSchema = viewedSearchSourceSchema.pick({ url: true })
18-
export type ViewedSearchSource = z.output<typeof recordSearchSourceSchema>
17+
export type ViewedSearchSource = z.output<typeof viewedSearchSourceSchema>
1918

2019
export const recordSearchHistoryBodySchema = z.discriminatedUnion('kind', [
21-
z.object({ kind: z.literal('source'), source: recordSearchSourceSchema }),
20+
z.object({ kind: z.literal('source'), source: viewedSearchSourceSchema }),
2221
z.object({
2322
kind: z.literal('query'),
2423
query: z.string().trim().min(1, 'Search query is required').max(4000),

‎apps/sim/lib/knowledge/application/search-history.ts‎

Lines changed: 6 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,11 @@ import {
55
import { defineOrganizationOperation } from '@/lib/core/application/organization-operation'
66
import { OrchestrationError } from '@/lib/core/orchestration/types'
77
import { requireOrganizationSearchAvailable } from '@/lib/knowledge/access/availability'
8-
import { createUserKnowledgeAccessProvider } from '@/lib/knowledge/access/scope'
98
import {
10-
readAccessibleHistorySources,
119
readSearchHistory,
1210
type SearchHistoryEvent,
1311
updateSearchHistory,
1412
} from '@/lib/knowledge/search/history/repository'
15-
import { findSearchIndex } from '@/lib/knowledge/search/search-index'
1613
import { isKnowledgeSourceUrl } from '@/lib/knowledge/search/source-url'
1714

1815
export const searchHistoryOperations = {
@@ -43,30 +40,15 @@ interface RecordSearchHistoryInput extends SearchHistoryInput {
4340
event: SearchHistoryEvent
4441
}
4542

46-
/** Navigation history belongs to the current person, never to the connector or organization collectively. */
43+
/**
44+
* Private navigation snapshots, not a current source catalog. Reading history does not grant
45+
* source access; the destination enforces current permissions when the person opens it.
46+
*/
4747
export const listSearchHistory = defineAuthorizedOrganizationUseCase({
4848
operation: searchHistoryOperations.list,
4949
authorizeResource: ({ context }) => requireOrganizationSearchAvailable(context.organizationId),
50-
async execute({ context, request }: OrganizationUseCaseContext<SearchHistoryInput>) {
51-
const history = await readSearchHistory(context)
52-
if (!history.sources.length) return { ...history, sources: [] }
53-
const index = await findSearchIndex({
54-
kind: 'organization',
55-
organizationId: context.organizationId,
56-
})
57-
if (!index) return { ...history, sources: [] }
58-
const access = createUserKnowledgeAccessProvider(context.userId, {
59-
organizationId: context.organizationId,
60-
knowledgeBaseIds: [index.id],
61-
signal: request?.signal,
62-
})
63-
const sources = await readAccessibleHistorySources(
64-
index.id,
65-
history.sources,
66-
access,
67-
request?.signal
68-
)
69-
return { ...history, sources }
50+
execute({ context }: OrganizationUseCaseContext<SearchHistoryInput>) {
51+
return readSearchHistory(context)
7052
},
7153
})
7254
export const recordSearchHistory = defineAuthorizedOrganizationUseCase({

‎apps/sim/lib/knowledge/search/history/history.integration.ts‎

Lines changed: 15 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,5 @@
11
import { db } from '@sim/db'
2-
import {
3-
document,
4-
knowledgeBase,
5-
knowledgeConnector,
6-
member,
7-
organization,
8-
organizationSearchHistory,
9-
organizationSearchIntegration,
10-
user,
11-
} from '@sim/db/schema'
2+
import { member, organization, organizationSearchHistory, user } from '@sim/db/schema'
123
import { generateId } from '@sim/utils/id'
134
import { and, eq, inArray } from 'drizzle-orm'
145
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -21,16 +12,10 @@ import { SEARCH_HISTORY_LIMIT } from '@/lib/knowledge/search/history/limits'
2112

2213
vi.mock('@/lib/knowledge/access/availability', () => ({
2314
requireOrganizationSearchAvailable: vi.fn(),
24-
resolveKnowledgeAccessAvailability: vi.fn(async () => ({
25-
sourceMirrored: true,
26-
memberScoped: true,
27-
})),
2815
}))
2916

3017
const userIds = [generateId(), generateId()]
3118
const orgIds = [generateId(), generateId()]
32-
const indexId = generateId()
33-
const connectorId = generateId()
3419
const principal = { kind: 'session', userId: userIds[0], sessionId: 'history-test' } as const
3520
const input = { organizationId: orgIds[0] }
3621
const scope = and(
@@ -56,20 +41,6 @@ beforeAll(async () => {
5641
await db
5742
.insert(organization)
5843
.values(orgIds.map((id) => ({ id, name: 'History fixture', slug: id })))
59-
await db.insert(knowledgeBase).values({
60-
id: indexId,
61-
organizationId: orgIds[0],
62-
userId: userIds[0],
63-
name: 'Search',
64-
isSearchIndex: true,
65-
})
66-
await db.insert(knowledgeConnector).values({
67-
id: connectorId,
68-
knowledgeBaseId: indexId,
69-
connectorType: 'google_drive',
70-
sourceConfig: {},
71-
accessMode: 'admin',
72-
})
7344
await db.insert(member).values(
7445
userIds.map((userId) => ({
7546
id: generateId(),
@@ -80,33 +51,6 @@ beforeAll(async () => {
8051
)
8152
})
8253
beforeEach(async () => {
83-
await db
84-
.delete(organizationSearchIntegration)
85-
.where(eq(organizationSearchIntegration.organizationId, orgIds[0]))
86-
await db
87-
.update(knowledgeConnector)
88-
.set({ archivedAt: null })
89-
.where(eq(knowledgeConnector.id, connectorId))
90-
await db.delete(document).where(eq(document.knowledgeBaseId, indexId))
91-
await db.insert(document).values(
92-
[
93-
source.url,
94-
'https://example.com/other',
95-
...Array.from({ length: 24 }, (_, i) => `https://example.com/${i}`),
96-
].map((url) => ({
97-
id: generateId(),
98-
knowledgeBaseId: indexId,
99-
filename: url === 'https://example.com/23' ? 'Updated title' : source.title,
100-
fileUrl: url,
101-
sourceUrl: url,
102-
fileSize: 1,
103-
mimeType: 'text/plain',
104-
acl: ['pub'],
105-
connectorId,
106-
aclVerifiedAt: new Date(),
107-
processingStatus: 'completed',
108-
}))
109-
)
11054
await db
11155
.delete(organizationSearchHistory)
11256
.where(inArray(organizationSearchHistory.organizationId, orgIds))
@@ -173,49 +117,20 @@ describe('private search navigation history', () => {
173117
expect((await listSearchHistory.execute({ principal, input })).sources).toHaveLength(1)
174118
})
175119

176-
it('revalidates current document access and metadata instead of replaying saved titles', async () => {
177-
await recordSearchHistory.execute({
178-
principal,
179-
input: { ...input, event: { kind: 'source', source } },
180-
})
181-
await db
182-
.update(document)
183-
.set({ filename: 'Current title' })
184-
.where(eq(document.sourceUrl, source.url))
185-
expect((await listSearchHistory.execute({ principal, input })).sources[0]?.title).toBe(
186-
'Current title'
120+
it('retains live and internal navigation without an organization search index', async () => {
121+
const visits = [
122+
{ ...source, siteName: 'Google Docs', connectorType: 'google_drive' },
123+
{ url: 'https://sim.example.com/o/fixture/knowledge/base/document', title: 'Upload' },
124+
]
125+
for (const visit of visits) {
126+
await recordSearchHistory.execute({
127+
principal,
128+
input: { ...input, event: { kind: 'source', source: visit } },
129+
})
130+
}
131+
expect((await listSearchHistory.execute({ principal, input })).sources).toMatchObject(
132+
[...visits].reverse()
187133
)
188-
await db
189-
.update(document)
190-
.set({ acl: ['u:another@fixture.test'] })
191-
.where(eq(document.sourceUrl, source.url))
192-
expect((await listSearchHistory.execute({ principal, input })).sources).toEqual([])
193-
await db
194-
.update(document)
195-
.set({ acl: ['pub'], userExcluded: true })
196-
.where(eq(document.sourceUrl, source.url))
197-
expect((await listSearchHistory.execute({ principal, input })).sources).toEqual([])
198-
await db.update(document).set({ userExcluded: false }).where(eq(document.sourceUrl, source.url))
199-
await db
200-
.insert(organizationSearchIntegration)
201-
.values({ organizationId: orgIds[0], connectorType: 'google_drive', approved: false })
202-
expect((await listSearchHistory.execute({ principal, input })).sources).toEqual([])
203-
await db
204-
.delete(organizationSearchIntegration)
205-
.where(eq(organizationSearchIntegration.organizationId, orgIds[0]))
206-
await db
207-
.update(knowledgeConnector)
208-
.set({ archivedAt: new Date() })
209-
.where(eq(knowledgeConnector.id, connectorId))
210-
expect((await listSearchHistory.execute({ principal, input })).sources).toEqual([])
211-
await recordSearchHistory.execute({
212-
principal,
213-
input: {
214-
...input,
215-
event: { kind: 'source', source: { ...source, url: 'https://example.com/unverifiable' } },
216-
},
217-
})
218-
expect((await listSearchHistory.execute({ principal, input })).sources).toEqual([])
219134
})
220135

221136
it('deduplicates and bounds concurrent visits without losing unrelated queries', async () => {
@@ -245,7 +160,7 @@ describe('private search navigation history', () => {
245160
...input,
246161
event: {
247162
kind: 'source',
248-
source: { ...source, url: 'https://example.com/23' },
163+
source: { ...source, url: 'https://example.com/23', title: 'Updated title' },
249164
},
250165
},
251166
})
Lines changed: 4 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,6 @@
11
import { db } from '@sim/db'
2-
import { document, knowledgeConnector, organizationSearchHistory } from '@sim/db/schema'
3-
import { truncateAtCodePoint } from '@sim/utils/string'
4-
import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm'
5-
import {
6-
knowledgeAccessCondition,
7-
knowledgeMetadataCandidateAccessCondition,
8-
} from '@/lib/knowledge/access/predicate'
9-
import {
10-
type KnowledgeAccessProvider,
11-
MAX_KNOWLEDGE_ACCESS_CANDIDATES,
12-
} from '@/lib/knowledge/access/types'
2+
import { organizationSearchHistory } from '@sim/db/schema'
3+
import { and, eq } from 'drizzle-orm'
134
import {
145
SEARCH_HISTORY_LIMIT,
156
SEARCH_HISTORY_MAX_AGE_MS,
@@ -38,8 +29,7 @@ function recentHistory(history: SearchHistory): SearchHistory {
3829
return {
3930
sources: history.sources
4031
.filter((source) => Date.parse(source.viewedAt) > cutoff)
41-
.slice(0, SEARCH_HISTORY_LIMIT)
42-
.map(({ url, viewedAt }) => ({ url, viewedAt })),
32+
.slice(0, SEARCH_HISTORY_LIMIT),
4333
queries: history.queries
4434
.filter((entry) => Date.parse(entry.searchedAt) > cutoff)
4535
.slice(0, SEARCH_HISTORY_LIMIT),
@@ -74,7 +64,7 @@ export async function updateSearchHistory(
7464
const now = new Date().toISOString()
7565
if (event?.kind === 'source') {
7666
history.sources = [
77-
{ url: event.source.url, viewedAt: now },
67+
{ ...event.source, viewedAt: now },
7868
...history.sources.filter((source) => source.url !== event.source.url),
7969
].slice(0, SEARCH_HISTORY_LIMIT)
8070
} else if (event?.kind === 'query') {
@@ -86,79 +76,3 @@ export async function updateSearchHistory(
8676
await tx.update(organizationSearchHistory).set(history).where(ownerCondition(owner))
8777
})
8878
}
89-
90-
/** Only current indexed metadata can leave the server; an unprovable saved URL is omitted. */
91-
export async function readAccessibleHistorySources(
92-
knowledgeBaseId: string,
93-
sources: SearchHistory['sources'],
94-
access: KnowledgeAccessProvider,
95-
signal?: AbortSignal
96-
) {
97-
if (!sources.length) return []
98-
signal?.throwIfAborted()
99-
const conditions = [
100-
eq(document.knowledgeBaseId, knowledgeBaseId),
101-
inArray(
102-
sql<string>`md5(${document.sourceUrl})`,
103-
sources.map(({ url }) => sql`md5(${url})`)
104-
),
105-
inArray(
106-
document.sourceUrl,
107-
sources.map(({ url }) => url)
108-
),
109-
eq(document.enabled, true),
110-
eq(document.userExcluded, false),
111-
isNull(document.archivedAt),
112-
isNull(document.deletedAt),
113-
]
114-
let scope = await access.get()
115-
let ids: string[] | undefined
116-
if (await access.liveSourceConnectorCondition()) {
117-
const candidates = await db
118-
.select({ id: document.id, connectorId: document.connectorId })
119-
.from(document)
120-
.where(and(...conditions, knowledgeMetadataCandidateAccessCondition(scope)))
121-
.orderBy(asc(document.id))
122-
.limit(MAX_KNOWLEDGE_ACCESS_CANDIDATES)
123-
if (!candidates.length) return []
124-
ids = candidates.map(({ id }) => id)
125-
scope = await access.getForConnectors(
126-
candidates.flatMap(({ connectorId }) => (connectorId ? [connectorId] : [])),
127-
signal
128-
)
129-
}
130-
signal?.throwIfAborted()
131-
const rows = await db
132-
.select({
133-
url: document.sourceUrl,
134-
title: sql<string>`left(${document.filename}, 512)`,
135-
connectorType: knowledgeConnector.connectorType,
136-
})
137-
.from(document)
138-
.leftJoin(knowledgeConnector, eq(knowledgeConnector.id, document.connectorId))
139-
.where(
140-
and(
141-
...conditions,
142-
ids ? inArray(document.id, ids) : undefined,
143-
knowledgeAccessCondition(scope)
144-
)
145-
)
146-
.orderBy(asc(document.id))
147-
.limit(MAX_KNOWLEDGE_ACCESS_CANDIDATES)
148-
const byUrl = new Map(rows.map((row) => [row.url, row]))
149-
return sources.flatMap(({ url, viewedAt }) => {
150-
const row = byUrl.get(url)
151-
return row
152-
? [
153-
{
154-
url,
155-
viewedAt,
156-
title: truncateAtCodePoint(row.title.trim(), 512, '') || undefined,
157-
connectorType: row.connectorType
158-
? truncateAtCodePoint(row.connectorType, 64, '')
159-
: undefined,
160-
},
161-
]
162-
: []
163-
})
164-
}

‎packages/db/migrations/0385_young_slipstream.sql‎

Lines changed: 0 additions & 6 deletions
This file was deleted.

0 commit comments

Comments
 (0)