Skip to content

Commit 1d33330

Browse files
committed
fix(knowledge): send tag filters as a JSON string so the document filter works
The document-list tag filter never reached the database. The `tagFilters` query field was a Zod `.transform()` that decoded the JSON string into an array of objects; the client's `requestJson` parses the query before serializing, so `appendQuery` received the array and emitted `tagFilters=[object Object]` into the URL. The route then failed to `JSON.parse` it and returned 400, so the list came back empty (or stale via keepPreviousData) regardless of operator or value. - Model `tagFilters` as the wire string it actually is; decode it server-side via a new `parseDocumentTagFiltersParam` helper (route maps a bad value to 400). - Harden `appendQuery`: throw on an array-of-objects query param instead of silently serializing `[object Object]`, so this whole class fails loudly. - Default the text tag-filter operator to `contains` so a partial value matches. - Tests: requestJson serializes the JSON param verbatim + the guard throws; the query schema keeps tagFilters a string; the decode helper round-trips. A full sweep of every GET/DELETE contract query field confirmed this was the only field of this class — logs filters and table filter/sort are unaffected.
1 parent bcf6a80 commit 1d33330

6 files changed

Lines changed: 176 additions & 17 deletions

File tree

apps/sim/app/api/knowledge/[id]/documents/route.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
bulkKnowledgeDocumentsContract,
99
createKnowledgeDocumentsContract,
1010
listKnowledgeDocumentsQuerySchema,
11+
parseDocumentTagFiltersParam,
1112
} from '@/lib/api/contracts/knowledge'
1213
import { parseRequest } from '@/lib/api/server'
1314
import { getSession } from '@/lib/auth'
@@ -67,6 +68,18 @@ export const GET = withRouteHandler(
6768
const { enabledFilter, search, limit, offset, sortBy, sortOrder, tagFilters } =
6869
queryResult.data
6970

71+
let parsedTagFilters: TagFilterCondition[] | undefined
72+
try {
73+
parsedTagFilters = parseDocumentTagFiltersParam(tagFilters) as
74+
| TagFilterCondition[]
75+
| undefined
76+
} catch {
77+
return NextResponse.json(
78+
{ error: 'tagFilters must be a valid JSON array' },
79+
{ status: 400 }
80+
)
81+
}
82+
7083
const result = await getDocuments(
7184
knowledgeBaseId,
7285
{
@@ -76,7 +89,7 @@ export const GET = withRouteHandler(
7689
offset,
7790
...(sortBy && { sortBy }),
7891
...(sortOrder && { sortOrder }),
79-
tagFilters: tagFilters as TagFilterCondition[] | undefined,
92+
tagFilters: parsedTagFilters,
8093
},
8194
requestId
8295
)

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1466,11 +1466,25 @@ const createEmptyEntry = (): TagFilterEntry => ({
14661466
tagName: '',
14671467
tagSlot: '',
14681468
fieldType: 'text',
1469-
operator: 'eq',
1469+
operator: 'contains',
14701470
value: '',
14711471
valueTo: '',
14721472
})
14731473

1474+
/**
1475+
* Default operator when a tag is selected. Text filters default to `contains`
1476+
* so typing part of a value finds matches (exact `equals` stays one click away
1477+
* in the operator dropdown); other field types keep their first, equality
1478+
* operator.
1479+
*/
1480+
function getDefaultOperatorForFieldType(
1481+
fieldType: FilterFieldType,
1482+
operators: ReturnType<typeof getOperatorsForFieldType>
1483+
): string {
1484+
if (fieldType === 'text') return 'contains'
1485+
return operators[0]?.value ?? 'eq'
1486+
}
1487+
14741488
interface TagFilterSectionProps {
14751489
tagDefinitions: TagDefinition[]
14761490
entries: TagFilterEntry[]
@@ -1601,7 +1615,7 @@ function TagFilterSection({ tagDefinitions, entries, onChange }: TagFilterSectio
16011615
tagName,
16021616
tagSlot: def?.tagSlot || '',
16031617
fieldType,
1604-
operator: operators[0]?.value || 'eq',
1618+
operator: getDefaultOperatorForFieldType(fieldType, operators),
16051619
value: '',
16061620
valueTo: '',
16071621
})
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { afterEach, describe, expect, it, vi } from 'vitest'
5+
import { z } from 'zod'
6+
import { requestJson } from '@/lib/api/client/request'
7+
import { listKnowledgeDocumentsContract } from '@/lib/api/contracts/knowledge'
8+
import { defineRouteContract } from '@/lib/api/contracts/types'
9+
10+
/**
11+
* Captures the URL of the last fetch call and returns a valid JSON response so
12+
* `requestJson`'s response validation passes.
13+
*/
14+
function mockFetchReturning(body: unknown) {
15+
const fetchMock = vi.fn(
16+
async () =>
17+
new Response(JSON.stringify(body), {
18+
status: 200,
19+
headers: { 'content-type': 'application/json' },
20+
})
21+
)
22+
vi.stubGlobal('fetch', fetchMock)
23+
return fetchMock
24+
}
25+
26+
describe('requestJson query serialization', () => {
27+
afterEach(() => {
28+
vi.unstubAllGlobals()
29+
})
30+
31+
it('serializes a JSON-string query param verbatim (regression: tagFilters)', async () => {
32+
const fetchMock = mockFetchReturning({
33+
success: true,
34+
data: {
35+
documents: [],
36+
pagination: { total: 0, limit: 50, offset: 0, hasMore: false },
37+
},
38+
})
39+
40+
const tagFilters = JSON.stringify([
41+
{ tagSlot: 'tag1', fieldType: 'text', operator: 'contains', value: 'Ada Lovelace' },
42+
])
43+
44+
await requestJson(listKnowledgeDocumentsContract, {
45+
params: { id: 'kb-1' },
46+
query: { tagFilters },
47+
})
48+
49+
const calledUrl = String(fetchMock.mock.calls[0][0])
50+
const url = new URL(calledUrl, 'https://example.test')
51+
// The param must round-trip as the exact JSON, never "[object Object]".
52+
expect(url.searchParams.get('tagFilters')).toBe(tagFilters)
53+
expect(calledUrl).not.toContain('object+Object')
54+
expect(calledUrl).not.toContain('[object Object]')
55+
})
56+
57+
it('throws instead of silently corrupting an array-of-objects query param', async () => {
58+
mockFetchReturning({ ok: true })
59+
60+
const badContract = defineRouteContract({
61+
method: 'GET',
62+
path: '/api/test',
63+
query: z.object({ items: z.array(z.object({ a: z.string() })) }),
64+
response: { mode: 'json', schema: z.object({ ok: z.boolean() }) },
65+
})
66+
67+
await expect(requestJson(badContract, { query: { items: [{ a: 'x' }] } })).rejects.toThrow(
68+
/arrays of objects are not URL-safe/
69+
)
70+
})
71+
})

apps/sim/lib/api/client/request.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,19 @@ function appendQuery(path: string, query: unknown): string {
6868

6969
if (Array.isArray(value)) {
7070
for (const item of value) {
71-
if (item !== undefined && item !== null && item !== '') {
72-
searchParams.append(key, String(item))
71+
if (item === undefined || item === null || item === '') continue
72+
// A non-scalar in a query array would stringify to "[object Object]" and
73+
// silently corrupt the request. Encode such values as a single JSON
74+
// string param and decode them server-side instead. Failing loudly here
75+
// keeps the boundary honest (this is how the knowledge tagFilters bug
76+
// shipped undetected).
77+
if (typeof item === 'object') {
78+
throw new Error(
79+
`Cannot serialize query param "${key}": arrays of objects are not URL-safe — ` +
80+
'encode the value as a JSON string param and decode it server-side.'
81+
)
7382
}
83+
searchParams.append(key, String(item))
7484
}
7585
continue
7686
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import {
6+
listKnowledgeDocumentsQuerySchema,
7+
parseDocumentTagFiltersParam,
8+
} from '@/lib/api/contracts/knowledge/documents'
9+
10+
describe('listKnowledgeDocumentsQuerySchema.tagFilters', () => {
11+
it('keeps tagFilters a raw string (must NOT transform to an array)', () => {
12+
// A transform-to-array here breaks requestJson outbound serialization
13+
// (the array serializes as "[object Object]"). The wire type must stay a
14+
// string; decoding happens server-side via parseDocumentTagFiltersParam.
15+
const tagFilters = JSON.stringify([
16+
{ tagSlot: 'tag1', fieldType: 'text', operator: 'contains', value: 'x' },
17+
])
18+
const parsed = listKnowledgeDocumentsQuerySchema.parse({ tagFilters })
19+
expect(parsed.tagFilters).toBe(tagFilters)
20+
expect(typeof parsed.tagFilters).toBe('string')
21+
})
22+
})
23+
24+
describe('parseDocumentTagFiltersParam', () => {
25+
it('returns undefined for an absent param', () => {
26+
expect(parseDocumentTagFiltersParam(undefined)).toBeUndefined()
27+
expect(parseDocumentTagFiltersParam('')).toBeUndefined()
28+
})
29+
30+
it('decodes a valid JSON array of filters', () => {
31+
const filters = [
32+
{ tagSlot: 'tag1', fieldType: 'text', operator: 'contains', value: 'x' },
33+
{ tagSlot: 'date1', fieldType: 'date', operator: 'eq', value: '2026-04-21' },
34+
]
35+
expect(parseDocumentTagFiltersParam(JSON.stringify(filters))).toEqual(filters)
36+
})
37+
38+
it('throws on malformed JSON', () => {
39+
expect(() => parseDocumentTagFiltersParam('[object Object]')).toThrow()
40+
expect(() => parseDocumentTagFiltersParam('{not json')).toThrow()
41+
})
42+
43+
it('throws when the shape is wrong', () => {
44+
expect(() => parseDocumentTagFiltersParam(JSON.stringify([{ tagSlot: '' }]))).toThrow()
45+
})
46+
})

apps/sim/lib/api/contracts/knowledge/documents.ts

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,20 +40,25 @@ export const listKnowledgeDocumentsQuerySchema = z.object({
4040
])
4141
.optional(),
4242
sortOrder: z.enum(['asc', 'desc']).optional(),
43-
tagFilters: z
44-
.string()
45-
.optional()
46-
.transform((value, ctx) => {
47-
if (!value) return undefined
48-
try {
49-
return z.array(documentTagFilterSchema).parse(JSON.parse(value))
50-
} catch {
51-
ctx.addIssue({ code: 'custom', message: 'tagFilters must be a valid JSON array' })
52-
return z.NEVER
53-
}
54-
}),
43+
// A query param is a string on the wire, so `tagFilters` is carried as a JSON
44+
// string and decoded by the route via `parseDocumentTagFiltersParam`. It must
45+
// NOT be a `.transform()` to an array here: the client's `requestJson` parses
46+
// the query before serializing it, so a transform would turn the string into
47+
// an array that serializes to `tagFilters=[object Object]` and 400s the route.
48+
tagFilters: z.string().optional(),
5549
})
5650

51+
/**
52+
* Decodes the `tagFilters` query string (a JSON array) into validated filters.
53+
* Throws on malformed JSON or a shape mismatch; callers map that to a 400.
54+
*/
55+
export function parseDocumentTagFiltersParam(
56+
value: string | undefined
57+
): DocumentTagFilter[] | undefined {
58+
if (!value) return undefined
59+
return z.array(documentTagFilterSchema).parse(JSON.parse(value))
60+
}
61+
5762
export const createDocumentBodySchema = z.object({
5863
filename: z.string().min(1, 'Filename is required'),
5964
fileUrl: knowledgeDocumentFileUrlSchema,

0 commit comments

Comments
 (0)