Skip to content

Commit fe7d6d3

Browse files
authored
fix(knowledge): bound source titles and tag values so an oversized index row cannot fail a connector sync (#8123)
* fix(knowledge): bound source titles and tag values so an oversized index row cannot fail a connector sync * test(knowledge): pin the exact indexed-text boundary * fix(knowledge): share the indexed-text bound, cut by code point, and reject oversized filenames and tags at the document APIs * fix(knowledge): cut bounded text with a shared surrogate-safe helper inside the limit and leave stored upload metadata untouched * fix(knowledge): leave values at the limit untouched and refuse over-long tag values on the tag-data and tag-update paths
1 parent 7a5fcee commit fe7d6d3

11 files changed

Lines changed: 241 additions & 27 deletions

File tree

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

Lines changed: 39 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,39 @@ 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 [
275+
createDocumentBodySchema,
276+
upsertDocumentBodySchema,
277+
updateDocumentBodySchema,
278+
]) {
279+
const result = schema.safeParse({ ...base, filename: overLimit })
280+
expect(result.success).toBe(false)
281+
expect(result.error?.issues[0]?.message).toBe(
282+
`Filename cannot exceed ${MAX_DOCUMENT_INDEXED_TEXT_LENGTH} characters`
283+
)
284+
}
285+
})
286+
287+
it('rejects a tag value over the limit on create and update', () => {
288+
for (const schema of [createDocumentBodySchema, updateDocumentBodySchema]) {
289+
const result = schema.safeParse({ ...base, filename: 'a.txt', tag3: overLimit })
290+
expect(result.success).toBe(false)
291+
expect(result.error?.issues[0]?.message).toBe(
292+
`Tag values cannot exceed ${MAX_DOCUMENT_INDEXED_TEXT_LENGTH} characters`
293+
)
294+
}
295+
})
296+
})

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

Lines changed: 49 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,32 @@ 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(
126+
MAX_DOCUMENT_INDEXED_TEXT_LENGTH,
127+
`Tag values cannot exceed ${MAX_DOCUMENT_INDEXED_TEXT_LENGTH} characters`
128+
)
129+
118130
export const createDocumentBodySchema = z.object({
119-
filename: z.string().min(1, 'Filename is required'),
131+
filename: z
132+
.string()
133+
.min(1, 'Filename is required')
134+
.max(
135+
MAX_DOCUMENT_INDEXED_TEXT_LENGTH,
136+
`Filename cannot exceed ${MAX_DOCUMENT_INDEXED_TEXT_LENGTH} characters`
137+
),
120138
fileUrl: knowledgeDocumentFileUrlSchema,
121139
fileSize: z.number().min(1, 'File size must be greater than 0'),
122140
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(),
141+
tag1: documentTagValueSchema.optional(),
142+
tag2: documentTagValueSchema.optional(),
143+
tag3: documentTagValueSchema.optional(),
144+
tag4: documentTagValueSchema.optional(),
145+
tag5: documentTagValueSchema.optional(),
146+
tag6: documentTagValueSchema.optional(),
147+
tag7: documentTagValueSchema.optional(),
130148
documentTagsData: z.string().optional(),
131149
})
132150

@@ -165,7 +183,13 @@ export type SingleCreateDocumentBody = z.input<typeof singleCreateDocumentBodySc
165183

166184
export const upsertDocumentBodySchema = z.object({
167185
documentId: z.string().optional(),
168-
filename: z.string().min(1, 'Filename is required'),
186+
filename: z
187+
.string()
188+
.min(1, 'Filename is required')
189+
.max(
190+
MAX_DOCUMENT_INDEXED_TEXT_LENGTH,
191+
`Filename cannot exceed ${MAX_DOCUMENT_INDEXED_TEXT_LENGTH} characters`
192+
),
169193
fileUrl: knowledgeDocumentFileUrlSchema,
170194
fileSize: z.number().min(1, 'File size must be greater than 0'),
171195
mimeType: z.string().min(1, 'MIME type is required'),
@@ -196,7 +220,14 @@ export const bulkCreateDocumentsResponseSchema = z.object({
196220
})
197221

198222
export const updateDocumentBodySchema = z.object({
199-
filename: z.string().min(1, 'Filename is required').optional(),
223+
filename: z
224+
.string()
225+
.min(1, 'Filename is required')
226+
.max(
227+
MAX_DOCUMENT_INDEXED_TEXT_LENGTH,
228+
`Filename cannot exceed ${MAX_DOCUMENT_INDEXED_TEXT_LENGTH} characters`
229+
)
230+
.optional(),
200231
enabled: z.boolean().optional(),
201232
chunkCount: z.number().min(0).optional(),
202233
tokenCount: z.number().min(0).optional(),
@@ -205,13 +236,13 @@ export const updateDocumentBodySchema = z.object({
205236
processingError: z.string().optional(),
206237
markFailedDueToTimeout: z.boolean().optional(),
207238
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(),
239+
tag1: documentTagValueSchema.optional(),
240+
tag2: documentTagValueSchema.optional(),
241+
tag3: documentTagValueSchema.optional(),
242+
tag4: documentTagValueSchema.optional(),
243+
tag5: documentTagValueSchema.optional(),
244+
tag6: documentTagValueSchema.optional(),
245+
tag7: documentTagValueSchema.optional(),
215246
number1: z.string().optional(),
216247
number2: z.string().optional(),
217248
number3: z.string().optional(),

‎apps/sim/lib/knowledge/application/documents.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ import {
6868
toKnowledgeTagFilterConditions,
6969
} from '@/lib/knowledge/tags/filter-resolution'
7070
import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service'
71-
import { validateTagValue } from '@/lib/knowledge/tags/utils'
71+
import { validateTagValue, validateTagValueLength } from '@/lib/knowledge/tags/utils'
7272
import { StorageService } from '@/lib/uploads'
7373
import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager'
7474
import { recordKnowledgeBaseFileOwnership } from '@/lib/uploads/server/metadata'
@@ -252,7 +252,9 @@ async function resolveKnowledgeDocumentTagValueUpdates(
252252
`Tag "${definition.displayName}" requires a value; use null to clear it`
253253
)
254254
}
255-
const validationError = validateTagValue(definition.displayName, value, definition.fieldType)
255+
const validationError =
256+
validateTagValueLength(definition.displayName, value) ??
257+
validateTagValue(definition.displayName, value, definition.fieldType)
256258
if (validationError) {
257259
throw new OrchestrationError('validation', validationError)
258260
}

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

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,24 @@ vi.mock('@/lib/knowledge/documents/storage-cleanup', () => ({
2727
}),
2828
isKnowledgeBaseOwnedStorageKey: (key: string) => key.startsWith('kb/'),
2929
}))
30-
vi.mock('@/connectors/registry.server', () => ({ CONNECTOR_REGISTRY: {} }))
30+
vi.mock('@/connectors/registry.server', () => ({
31+
CONNECTOR_REGISTRY: {
32+
fixture: {
33+
mapTags: (metadata: Record<string, unknown>) => ({
34+
label: metadata.label,
35+
owner: metadata.owner,
36+
}),
37+
},
38+
},
39+
}))
3140

3241
import { MAX_ACL_TOKENS } from '@/lib/knowledge/access/tokens'
3342
import { getConnectorFailureDiagnostic } from '@/lib/knowledge/connectors/connector-error'
3443
import {
3544
addDocument,
3645
persistDocumentAcls,
3746
persistSourceDocumentFailures,
47+
resolveTagMapping,
3848
} from '@/lib/knowledge/connectors/sync-persistence'
3949

4050
const CONNECTOR = 'connector-1'
@@ -350,6 +360,18 @@ describe('persistSourceDocumentFailures', () => {
350360
expect(JSON.stringify(dbChainMockFns.set.mock.calls)).not.toContain('private body')
351361
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
352362
})
363+
it('bounds a source title that would exceed the filename index row limit', async () => {
364+
leaseHeld()
365+
const title = 'x'.repeat(5000)
366+
await persistSourceDocumentFailures({
367+
...input,
368+
documents: [{ ...input.documents[0], title }],
369+
priorByExternalId: new Map(),
370+
})
371+
const [rows] = dbChainMockFns.values.mock.calls[0] as [Array<{ filename: string }>]
372+
expect(rows[0].filename).toBe(`${'x'.repeat(509)}...`)
373+
expect(rows[0].filename.length).toBe(512)
374+
})
353375
it('refuses to commit a failure under a reclaimed lease', async () => {
354376
queueTableRows(schemaMock.knowledgeBase, [{ id: 'kb' }])
355377
await expect(
@@ -416,3 +438,35 @@ describe('organization source cache persistence', () => {
416438
expect(mockUploadFile).not.toHaveBeenCalled()
417439
})
418440
})
441+
442+
describe('resolveTagMapping', () => {
443+
it('bounds a mapped tag value that would exceed its index row limit and keeps a short one intact', () => {
444+
const tags = resolveTagMapping(
445+
'fixture',
446+
{ label: 'y'.repeat(5000), owner: 'Purchasing' },
447+
{ tagSlotMapping: { label: 'tag1', owner: 'tag2' } }
448+
)
449+
expect(tags?.tag1).toBe(`${'y'.repeat(509)}...`)
450+
expect(tags?.tag2).toBe('Purchasing')
451+
})
452+
453+
it('keeps a value exactly at the limit untouched', () => {
454+
const atLimit = 'z'.repeat(512)
455+
const tags = resolveTagMapping(
456+
'fixture',
457+
{ label: atLimit },
458+
{ tagSlotMapping: { label: 'tag1' } }
459+
)
460+
expect(tags?.tag1).toBe(atLimit)
461+
})
462+
463+
it('cuts by code point so a bounded value never ends in half a surrogate pair', () => {
464+
const tags = resolveTagMapping(
465+
'fixture',
466+
{ label: '\u{1F600}'.repeat(600) },
467+
{ tagSlotMapping: { label: 'tag1' } }
468+
)
469+
expect(tags?.tag1).toBe(`${'\u{1F600}'.repeat(254)}...`)
470+
expect(tags?.tag1?.length).toBeLessThanOrEqual(512)
471+
})
472+
})

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

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ 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 { truncateAtCodePoint } from '@sim/utils/string'
67
import { and, eq, exists, inArray, isNull, lt, or, sql } from 'drizzle-orm'
78
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
89
import type { DbOrTx } from '@/lib/db/types'
@@ -18,6 +19,7 @@ import type { ConnectorFailureDiagnostic } from '@/lib/knowledge/connectors/conn
1819
import { resolveSourceModifiedAt } from '@/lib/knowledge/connectors/source-modified-at'
1920
import { SOURCE_CONTENT_ERROR } from '@/lib/knowledge/connectors/sync-limits'
2021
import { assertSyncLeaseHeldInTx, type SyncWriteLease } from '@/lib/knowledge/connectors/sync-lock'
22+
import { MAX_DOCUMENT_INDEXED_TEXT_LENGTH } from '@/lib/knowledge/constants'
2123
import type { DocumentData } from '@/lib/knowledge/documents/service'
2224
import { enqueueKnowledgeStorageCleanup } from '@/lib/knowledge/documents/storage-cleanup'
2325
import {
@@ -173,6 +175,23 @@ export async function persistDocumentAcls(
173175

174176
const MAX_SAFE_TITLE_LENGTH = 200
175177

178+
/** The suffix a cut value carries, counted inside {@link MAX_DOCUMENT_INDEXED_TEXT_LENGTH}. */
179+
const INDEXED_TEXT_CUT_SUFFIX = '...'
180+
181+
/**
182+
* Source titles and mapped tag values are untrusted machine input with no caller to refuse them,
183+
* so they are cut to {@link MAX_DOCUMENT_INDEXED_TEXT_LENGTH} code units, suffix included, and
184+
* never inside a surrogate pair. The result always passes the document APIs' own bound.
185+
*/
186+
function boundIndexedText(value: string): string {
187+
if (value.length <= MAX_DOCUMENT_INDEXED_TEXT_LENGTH) return value
188+
return truncateAtCodePoint(
189+
value,
190+
MAX_DOCUMENT_INDEXED_TEXT_LENGTH - INDEXED_TEXT_CUT_SUFFIX.length,
191+
INDEXED_TEXT_CUT_SUFFIX
192+
)
193+
}
194+
176195
function sanitizeStorageTitle(title: string): string {
177196
return title.replace(/[^a-zA-Z0-9.-]/g, '_').slice(0, MAX_SAFE_TITLE_LENGTH)
178197
}
@@ -250,7 +269,8 @@ export function resolveTagMapping(
250269
const result: Partial<DocumentTags> = {}
251270
for (const [semanticKey, slot] of Object.entries(mapping)) {
252271
const value = semanticTags[semanticKey]
253-
;(result as Record<string, unknown>)[slot] = value != null ? value : null
272+
;(result as Record<string, unknown>)[slot] =
273+
typeof value === 'string' ? boundIndexedText(value) : (value ?? null)
254274
}
255275
return result
256276
}
@@ -278,7 +298,7 @@ function buildSkippedDocumentRow(
278298
return {
279299
id: generateId(),
280300
knowledgeBaseId,
281-
filename: extDoc.title,
301+
filename: boundIndexedText(extDoc.title),
282302
fileUrl: '',
283303
storageKey: null,
284304
/** No artifact was stored; a provider's reported source size is not local storage usage. */
@@ -630,7 +650,7 @@ export async function addDocument(
630650
await tx.insert(document).values({
631651
id: documentId,
632652
knowledgeBaseId,
633-
filename: extDoc.title,
653+
filename: boundIndexedText(extDoc.title),
634654
fileUrl,
635655
storageKey: fileInfo.key,
636656
fileSize: artifact.bytes.length,
@@ -745,7 +765,7 @@ export async function updateDocument(
745765
await tx
746766
.update(document)
747767
.set({
748-
filename: extDoc.title,
768+
filename: boundIndexedText(extDoc.title),
749769
fileUrl,
750770
storageKey: fileInfo.key,
751771
fileSize: artifact.bytes.length,

‎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/documents/service.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ import {
179179
parseNumberValue,
180180
uncompilableTagFilterError,
181181
validateTagValue,
182+
validateTagValueLength,
182183
} from '@/lib/knowledge/tags/utils'
183184
import type { ProcessedDocumentTags } from '@/lib/knowledge/types'
184185
import { embeddingVectorValues } from '@/lib/knowledge/vector-columns'
@@ -552,7 +553,9 @@ function resolveDocumentTags(
552553

553554
const rawValue = typeof tag.value === 'string' ? tag.value.trim() : tag.value
554555
const actualFieldType = existingDef.fieldType || fieldType
555-
const validationError = validateTagValue(tagName, String(rawValue), actualFieldType)
556+
const validationError =
557+
validateTagValueLength(tagName, String(rawValue)) ??
558+
validateTagValue(tagName, String(rawValue), actualFieldType)
556559
if (validationError) {
557560
typeErrors.push(validationError)
558561
}

‎apps/sim/lib/knowledge/tags/utils.test.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5-
import { coerceTagFilterValue, validateTagValue } from '@/lib/knowledge/tags/utils'
5+
import {
6+
coerceTagFilterValue,
7+
validateTagValue,
8+
validateTagValueLength,
9+
} from '@/lib/knowledge/tags/utils'
610

711
describe('coerceTagFilterValue', () => {
812
it('accepts exactly what validateTagValue accepts', () => {
@@ -68,3 +72,12 @@ describe('validateTagValue', () => {
6872
expect(validateTagValue('name', 'anything', 'json')).toBeNull()
6973
})
7074
})
75+
76+
describe('validateTagValueLength', () => {
77+
it('accepts a value at the indexed-text limit and names the tag past it', () => {
78+
expect(validateTagValueLength('Labels', 'a'.repeat(512))).toBeNull()
79+
expect(validateTagValueLength('Labels', 'a'.repeat(513))).toBe(
80+
'Tag "Labels" cannot exceed 512 characters'
81+
)
82+
})
83+
})

0 commit comments

Comments
 (0)