Skip to content

Commit 20f9277

Browse files
committed
fix(knowledge): share the indexed-text bound, cut by code point, and reject oversized filenames and tags at the document APIs
1 parent 7207974 commit 20f9277

7 files changed

Lines changed: 114 additions & 36 deletions

File tree

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@ import { describe, expect, it } from 'vitest'
55
import { z } from 'zod'
66
import {
77
bulkCreateDocumentsBodySchema,
8+
createDocumentBodySchema,
89
documentDataSchema,
910
listKnowledgeDocumentsQuerySchema,
1011
parseDocumentTagFiltersParam,
12+
updateDocumentBodySchema,
1113
upsertDocumentBodySchema,
1214
} from '@/lib/api/contracts/knowledge/documents'
15+
import { MAX_DOCUMENT_INDEXED_TEXT_LENGTH } from '@/lib/knowledge/constants'
1316
import { getDocumentIndexingStatus } from '@/lib/knowledge/documents/types'
1417

1518
describe('document processing response compatibility', () => {
@@ -255,3 +258,35 @@ describe('internal document processingOptions', () => {
255258
})
256259
})
257260
})
261+
262+
describe('document filename and tag bounds', () => {
263+
const base = { fileUrl: 'https://example.com/a.txt', fileSize: 1, mimeType: 'text/plain' }
264+
const atLimit = 'a'.repeat(MAX_DOCUMENT_INDEXED_TEXT_LENGTH)
265+
const overLimit = `${atLimit}a`
266+
267+
it('accepts a filename and tag exactly at the indexed-text limit', () => {
268+
expect(
269+
createDocumentBodySchema.safeParse({ ...base, filename: atLimit, tag1: atLimit }).success
270+
).toBe(true)
271+
})
272+
273+
it('rejects a filename over the limit on create, upsert, and update with a descriptive message', () => {
274+
for (const schema of [createDocumentBodySchema, upsertDocumentBodySchema, updateDocumentBodySchema]) {
275+
const result = schema.safeParse({ ...base, filename: overLimit })
276+
expect(result.success).toBe(false)
277+
expect(result.error?.issues[0]?.message).toBe(
278+
`Filename cannot exceed ${MAX_DOCUMENT_INDEXED_TEXT_LENGTH} characters`
279+
)
280+
}
281+
})
282+
283+
it('rejects a tag value over the limit on create and update', () => {
284+
for (const schema of [createDocumentBodySchema, updateDocumentBodySchema]) {
285+
const result = schema.safeParse({ ...base, filename: 'a.txt', tag3: overLimit })
286+
expect(result.success).toBe(false)
287+
expect(result.error?.issues[0]?.message).toBe(
288+
`Tag values cannot exceed ${MAX_DOCUMENT_INDEXED_TEXT_LENGTH} characters`
289+
)
290+
}
291+
})
292+
})

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

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ import {
1515
import { privateSecretProvenanceBundleSchema } from '@/lib/api/contracts/primitives'
1616
import { defineRouteContract } from '@/lib/api/contracts/types'
1717
import { PRIVATE_SECRET_PROVENANCE_FIELD } from '@/lib/execution/private-tool-metadata'
18-
import { getFieldTypeForSlot, MAX_KNOWLEDGE_DOCUMENTS_PER_CREATE } from '@/lib/knowledge/constants'
18+
import {
19+
getFieldTypeForSlot,
20+
MAX_DOCUMENT_INDEXED_TEXT_LENGTH,
21+
MAX_KNOWLEDGE_DOCUMENTS_PER_CREATE,
22+
} from '@/lib/knowledge/constants'
1923
import { DOCUMENT_PROCESSING_STATUSES } from '@/lib/knowledge/documents/types'
2024
import { getOperatorsForFieldType, isValidFilterValue } from '@/lib/knowledge/filters/types'
2125
import { knowledgeDocumentUploadMetadataSchema } from '@/lib/knowledge/upload-metadata'
@@ -115,18 +119,26 @@ export function parseDocumentTagFiltersParam(
115119
return z.array(documentTagFilterSchema).parse(JSON.parse(value))
116120
}
117121

122+
/** A text tag value that fits its index row; see {@link MAX_DOCUMENT_INDEXED_TEXT_LENGTH}. */
123+
const documentTagValueSchema = z
124+
.string()
125+
.max(MAX_DOCUMENT_INDEXED_TEXT_LENGTH, `Tag values cannot exceed ${MAX_DOCUMENT_INDEXED_TEXT_LENGTH} characters`)
126+
118127
export const createDocumentBodySchema = z.object({
119-
filename: z.string().min(1, 'Filename is required'),
128+
filename: z
129+
.string()
130+
.min(1, 'Filename is required')
131+
.max(MAX_DOCUMENT_INDEXED_TEXT_LENGTH, `Filename cannot exceed ${MAX_DOCUMENT_INDEXED_TEXT_LENGTH} characters`),
120132
fileUrl: knowledgeDocumentFileUrlSchema,
121133
fileSize: z.number().min(1, 'File size must be greater than 0'),
122134
mimeType: z.string().min(1, 'MIME type is required'),
123-
tag1: z.string().optional(),
124-
tag2: z.string().optional(),
125-
tag3: z.string().optional(),
126-
tag4: z.string().optional(),
127-
tag5: z.string().optional(),
128-
tag6: z.string().optional(),
129-
tag7: z.string().optional(),
135+
tag1: documentTagValueSchema.optional(),
136+
tag2: documentTagValueSchema.optional(),
137+
tag3: documentTagValueSchema.optional(),
138+
tag4: documentTagValueSchema.optional(),
139+
tag5: documentTagValueSchema.optional(),
140+
tag6: documentTagValueSchema.optional(),
141+
tag7: documentTagValueSchema.optional(),
130142
documentTagsData: z.string().optional(),
131143
})
132144

@@ -165,7 +177,10 @@ export type SingleCreateDocumentBody = z.input<typeof singleCreateDocumentBodySc
165177

166178
export const upsertDocumentBodySchema = z.object({
167179
documentId: z.string().optional(),
168-
filename: z.string().min(1, 'Filename is required'),
180+
filename: z
181+
.string()
182+
.min(1, 'Filename is required')
183+
.max(MAX_DOCUMENT_INDEXED_TEXT_LENGTH, `Filename cannot exceed ${MAX_DOCUMENT_INDEXED_TEXT_LENGTH} characters`),
169184
fileUrl: knowledgeDocumentFileUrlSchema,
170185
fileSize: z.number().min(1, 'File size must be greater than 0'),
171186
mimeType: z.string().min(1, 'MIME type is required'),
@@ -196,7 +211,10 @@ export const bulkCreateDocumentsResponseSchema = z.object({
196211
})
197212

198213
export const updateDocumentBodySchema = z.object({
199-
filename: z.string().min(1, 'Filename is required').optional(),
214+
filename: z
215+
.string()
216+
.min(1, 'Filename is required')
217+
.max(MAX_DOCUMENT_INDEXED_TEXT_LENGTH, `Filename cannot exceed ${MAX_DOCUMENT_INDEXED_TEXT_LENGTH} characters`).optional(),
200218
enabled: z.boolean().optional(),
201219
chunkCount: z.number().min(0).optional(),
202220
tokenCount: z.number().min(0).optional(),
@@ -205,13 +223,13 @@ export const updateDocumentBodySchema = z.object({
205223
processingError: z.string().optional(),
206224
markFailedDueToTimeout: z.boolean().optional(),
207225
retryProcessing: z.boolean().optional(),
208-
tag1: z.string().optional(),
209-
tag2: z.string().optional(),
210-
tag3: z.string().optional(),
211-
tag4: z.string().optional(),
212-
tag5: z.string().optional(),
213-
tag6: z.string().optional(),
214-
tag7: z.string().optional(),
226+
tag1: documentTagValueSchema.optional(),
227+
tag2: documentTagValueSchema.optional(),
228+
tag3: documentTagValueSchema.optional(),
229+
tag4: documentTagValueSchema.optional(),
230+
tag5: documentTagValueSchema.optional(),
231+
tag6: documentTagValueSchema.optional(),
232+
tag7: documentTagValueSchema.optional(),
215233
number1: z.string().optional(),
216234
number2: z.string().optional(),
217235
number3: z.string().optional(),

‎apps/sim/lib/knowledge/connectors/sync-persistence.test.ts‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ vi.mock('@/lib/knowledge/documents/storage-cleanup', () => ({
2929
}))
3030
vi.mock('@/connectors/registry.server', () => ({
3131
CONNECTOR_REGISTRY: {
32-
fixture: { mapTags: (metadata: Record<string, unknown>) => ({ label: metadata.label }) },
32+
fixture: {
33+
mapTags: (metadata: Record<string, unknown>) => ({ label: metadata.label, owner: metadata.owner }),
34+
},
3335
},
3436
}))
3537

@@ -434,21 +436,22 @@ describe('organization source cache persistence', () => {
434436
})
435437

436438
describe('resolveTagMapping', () => {
437-
it('bounds a mapped tag value that would exceed its index row limit', () => {
439+
it('bounds a mapped tag value that would exceed its index row limit and keeps a short one intact', () => {
438440
const tags = resolveTagMapping(
439441
'fixture',
440-
{ label: 'y'.repeat(5000) },
441-
{ tagSlotMapping: { label: 'tag1' } }
442+
{ label: 'y'.repeat(5000), owner: 'Purchasing' },
443+
{ tagSlotMapping: { label: 'tag1', owner: 'tag2' } }
442444
)
443445
expect(tags?.tag1).toBe(`${'y'.repeat(512)}...`)
446+
expect(tags?.tag2).toBe('Purchasing')
444447
})
445448

446-
it('keeps a short mapped tag value intact', () => {
449+
it('cuts by code point so a bounded value never ends in half a surrogate pair', () => {
447450
const tags = resolveTagMapping(
448451
'fixture',
449-
{ label: 'Purchasing' },
452+
{ label: '\u{1F600}'.repeat(600) },
450453
{ tagSlotMapping: { label: 'tag1' } }
451454
)
452-
expect(tags?.tag1).toBe('Purchasing')
455+
expect(tags?.tag1).toBe(`${'\u{1F600}'.repeat(512)}...`)
453456
})
454457
})

‎apps/sim/lib/knowledge/connectors/sync-persistence.ts‎

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { document, embedding, knowledgeBase, knowledgeConnector } from '@sim/db/
33
import { createLogger } from '@sim/logger'
44
import { chunkArray } from '@sim/utils/helpers'
55
import { generateId } from '@sim/utils/id'
6-
import { truncate } from '@sim/utils/string'
76
import { and, eq, exists, inArray, isNull, lt, or, sql } from 'drizzle-orm'
87
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
98
import type { DbOrTx } from '@/lib/db/types'
@@ -27,6 +26,7 @@ import {
2726
} from '@/lib/knowledge/documents/storage-upload'
2827
import { buildStorageKeySegment } from '@/lib/uploads/core/storage-key'
2928
import { getFileMetadataByKeys } from '@/lib/uploads/server/metadata'
29+
import { MAX_DOCUMENT_INDEXED_TEXT_LENGTH } from '@/lib/knowledge/constants'
3030
import { CONNECTOR_REGISTRY } from '@/connectors/registry.server'
3131
import type { DocumentTags, ExternalDocument } from '@/connectors/types'
3232

@@ -175,16 +175,15 @@ export async function persistDocumentAcls(
175175
const MAX_SAFE_TITLE_LENGTH = 200
176176

177177
/**
178-
* A document's filename and text tags sit under btree indexes, and Postgres refuses an index
179-
* row past about 2.7 KB. A source title or tag value beyond that fails the row and, with it,
180-
* every sync that lists the document again. 512 characters keeps a four-byte-per-character
181-
* value inside the ceiling.
178+
* Source titles and mapped tag values are untrusted machine input with no caller to refuse them,
179+
* so they are cut to {@link MAX_DOCUMENT_INDEXED_TEXT_LENGTH} by code point, never inside a
180+
* surrogate pair.
182181
*/
183-
const MAX_INDEXED_TEXT_LENGTH = 512
184-
185-
/** Bounds a source-supplied value that lands in an indexed text column. */
186182
function boundIndexedText(value: string): string {
187-
return truncate(value, MAX_INDEXED_TEXT_LENGTH)
183+
if (value.length <= MAX_DOCUMENT_INDEXED_TEXT_LENGTH) return value
184+
const points = Array.from(value)
185+
if (points.length <= MAX_DOCUMENT_INDEXED_TEXT_LENGTH) return value
186+
return `${points.slice(0, MAX_DOCUMENT_INDEXED_TEXT_LENGTH).join('')}...`
188187
}
189188

190189
function sanitizeStorageTitle(title: string): string {
@@ -265,7 +264,7 @@ export function resolveTagMapping(
265264
for (const [semanticKey, slot] of Object.entries(mapping)) {
266265
const value = semanticTags[semanticKey]
267266
;(result as Record<string, unknown>)[slot] =
268-
typeof value === 'string' ? boundIndexedText(value) : value != null ? value : null
267+
typeof value === 'string' ? boundIndexedText(value) : (value ?? null)
269268
}
270269
return result
271270
}

‎apps/sim/lib/knowledge/constants.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@ import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants'
33
/** Max character length for a knowledge base description, enforced at every layer (UI, internal API, v1 API). */
44
export const KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH = 10_000
55

6+
/**
7+
* Max character length for a document's filename and text tag values. Both sit under btree
8+
* indexes, and Postgres refuses an index row past about 2.7 KB (SQLSTATE 54000); 512 characters
9+
* keeps a four-byte-per-character value inside that ceiling. Connectors truncate source titles to
10+
* it; the document APIs reject longer input.
11+
*/
12+
export const MAX_DOCUMENT_INDEXED_TEXT_LENGTH = 512
13+
614
/** Hard bound for path-indexed knowledge folder trees and recursive cascades. */
715
export const MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE = MAX_FOLDERS_PER_WORKSPACE
816

‎apps/sim/lib/knowledge/upload-metadata.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,19 @@ import {
77
knowledgeDocumentUploadMetadataSchema,
88
persistedKnowledgeDocumentUploadMetadataSchema,
99
} from '@/lib/knowledge/upload-metadata'
10+
import { MAX_DOCUMENT_INDEXED_TEXT_LENGTH } from '@/lib/knowledge/constants'
1011

1112
describe('knowledgeDocumentUploadMetadataSchema', () => {
13+
it('rejects a tag value that would not fit its index row', () => {
14+
const result = knowledgeDocumentUploadMetadataSchema.safeParse({
15+
tag1: 'a'.repeat(MAX_DOCUMENT_INDEXED_TEXT_LENGTH + 1),
16+
})
17+
expect(result.success).toBe(false)
18+
expect(result.error?.issues[0]?.message).toBe(
19+
`Knowledge document tag values cannot exceed ${MAX_DOCUMENT_INDEXED_TEXT_LENGTH} characters`
20+
)
21+
})
22+
1223
it('rejects a recipe outside the accepted set', () => {
1324
const result = knowledgeDocumentUploadMetadataSchema.safeParse({
1425
processingOptions: { recipe: 'totally-bogus-recipe' },

‎apps/sim/lib/knowledge/upload-metadata.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { z } from 'zod'
22
import type { RecursiveRecipe } from '@/lib/chunkers/types'
3+
import { MAX_DOCUMENT_INDEXED_TEXT_LENGTH } from '@/lib/knowledge/constants'
34

45
/**
56
* Recipes the recursive chunker implements. Mirrors `RecursiveRecipe`; the
@@ -35,7 +36,10 @@ const LANGUAGE_TAG_SHAPE = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/
3536

3637
const knowledgeDocumentUploadTagSchema = z
3738
.string()
38-
.max(1000, 'Knowledge document tag values cannot exceed 1000 characters')
39+
.max(
40+
MAX_DOCUMENT_INDEXED_TEXT_LENGTH,
41+
`Knowledge document tag values cannot exceed ${MAX_DOCUMENT_INDEXED_TEXT_LENGTH} characters`
42+
)
3943
.optional()
4044

4145
const knowledgeDocumentUploadTagShape = {

0 commit comments

Comments
 (0)