fix(knowledge): harden ingestion pipeline (#7077)

* fix(knowledge): harden ingestion pipeline

* fix(knowledge): correct bounded ingestion edge cases

* fix(knowledge): tighten redaction and chunk validation

* fix(knowledge): close bounded ingestion gaps

* fix(files): preserve parser complexity limits
This commit is contained in:
Waleed
2026-08-25 17:55:56 -07:00
committed by GitHub
parent b99411a86f
commit 0c38037bf1
133 changed files with 36975 additions and 2401 deletions
+8
View File
@@ -5278,6 +5278,13 @@
"maximum": 9007199254740991,
"description": "Documents unchanged."
},
"docsSkipped": {
"default": 0,
"description": "Documents intentionally skipped because they could not be indexed safely.",
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"docsFailed": {
"type": "integer",
"minimum": 0,
@@ -5306,6 +5313,7 @@
"docsUpdated",
"docsDeleted",
"docsUnchanged",
"docsSkipped",
"docsFailed",
"errorMessage"
],
@@ -16,6 +16,7 @@ import {
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { FileParserError } from '@/lib/file-parsers/errors'
const {
mockVerifyFileAccess,
@@ -310,6 +311,30 @@ describe('File Parse API Route', () => {
expect(data.output.content).toBe('plain text content')
})
it('should reject parser complexity limits instead of returning raw text', async () => {
setupFileApiMocks({
cloudEnabled: true,
storageProvider: 's3',
authenticated: true,
})
storageServiceMockFns.mockDownloadFile.mockResolvedValue(Buffer.from('{"value":true}'))
mockParseBuffer.mockRejectedValueOnce(
new FileParserError('complexity_limit', 'JSON document exceeds the complexity limit')
)
const req = createMockRequest('POST', {
filePath: '/api/files/serve/execution/workspace-1/workflow-1/execution-1/data.json',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(false)
expect(data.error).toContain('complexity limit')
expect(data).not.toHaveProperty('output')
})
it('should handle multiple files', async () => {
setupFileApiMocks({
cloudEnabled: false,
+4 -6
View File
@@ -13,8 +13,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid'
import { sanitizeUrlForLog } from '@/lib/core/utils/logging'
import { assertKnownSizeWithinLimit, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { isSupportedFileType, parseFile } from '@/lib/file-parsers'
import { isHtmlComplexityError } from '@/lib/file-parsers/html-parser'
import { isYamlComplexityError } from '@/lib/file-parsers/yaml-parser'
import { isFileParserError } from '@/lib/file-parsers/errors'
import { isUsingCloudStorage, StorageService } from '@/lib/uploads'
import { uploadExecutionFile } from '@/lib/uploads/contexts/execution'
import {
@@ -1050,10 +1049,9 @@ async function handleGenericTextBuffer(
}
} catch (parserError) {
if (isPayloadSizeLimitError(parserError)) throw parserError
// Fail closed on a resource-exhaustion rejection instead of silently
// storing the crafted document as raw text.
if (isYamlComplexityError(parserError)) throw parserError
if (isHtmlComplexityError(parserError)) throw parserError
if (isFileParserError(parserError) && parserError.code === 'complexity_limit') {
throw parserError
}
logger.warn('Specialized parser failed, falling back to generic parsing:', parserError)
}
+23 -44
View File
@@ -89,6 +89,20 @@ function makeResult(id: string, distance = 0.1): SearchResult {
}
}
const TEST_EMBEDDING = [0.1, 0.2, 0.3, ...Array.from({ length: 1533 }, () => 0)]
function mockNextEmbeddingResponse(): void {
vi.mocked(fetch).mockResolvedValueOnce(
new Response(
JSON.stringify({
data: [{ embedding: TEST_EMBEDDING, index: 0 }],
usage: { prompt_tokens: 1, total_tokens: 1 },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
)
}
describe('Knowledge Search Utils', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -553,12 +567,7 @@ describe('Knowledge Search Utils', () => {
OPENAI_API_KEY: 'test-openai-key',
})
mockNextFetchResponse({
json: {
data: [{ embedding: [0.1, 0.2, 0.3] }],
usage: { prompt_tokens: 1, total_tokens: 1 },
},
})
mockNextEmbeddingResponse()
const result = await generateSearchEmbedding('test query')
@@ -570,7 +579,7 @@ describe('Knowledge Search Utils', () => {
}),
})
)
expect(result.embedding).toEqual([0.1, 0.2, 0.3])
expect(result.embedding).toEqual(TEST_EMBEDDING)
// Clean up
Object.keys(env).forEach((key) => delete (env as any)[key])
@@ -583,12 +592,7 @@ describe('Knowledge Search Utils', () => {
OPENAI_API_KEY: 'test-openai-key',
})
mockNextFetchResponse({
json: {
data: [{ embedding: [0.1, 0.2, 0.3] }],
usage: { prompt_tokens: 1, total_tokens: 1 },
},
})
mockNextEmbeddingResponse()
const result = await generateSearchEmbedding('test query')
@@ -600,7 +604,7 @@ describe('Knowledge Search Utils', () => {
}),
})
)
expect(result.embedding).toEqual([0.1, 0.2, 0.3])
expect(result.embedding).toEqual(TEST_EMBEDDING)
// Clean up
Object.keys(env).forEach((key) => delete (env as any)[key])
@@ -616,12 +620,7 @@ describe('Knowledge Search Utils', () => {
OPENAI_API_KEY: 'test-openai-key',
})
mockNextFetchResponse({
json: {
data: [{ embedding: [0.1, 0.2, 0.3] }],
usage: { prompt_tokens: 1, total_tokens: 1 },
},
})
mockNextEmbeddingResponse()
await generateSearchEmbedding('test query')
@@ -645,12 +644,7 @@ describe('Knowledge Search Utils', () => {
OPENAI_API_KEY: 'test-openai-key',
})
mockNextFetchResponse({
json: {
data: [{ embedding: [0.1, 0.2, 0.3] }],
usage: { prompt_tokens: 1, total_tokens: 1 },
},
})
mockNextEmbeddingResponse()
await generateSearchEmbedding('test query', 'text-embedding-3-small')
@@ -733,12 +727,7 @@ describe('Knowledge Search Utils', () => {
KB_OPENAI_MODEL_NAME: 'text-embedding-ada-002',
})
mockNextFetchResponse({
json: {
data: [{ embedding: [0.1, 0.2, 0.3] }],
usage: { prompt_tokens: 1, total_tokens: 1 },
},
})
mockNextEmbeddingResponse()
await generateSearchEmbedding('test query')
@@ -764,12 +753,7 @@ describe('Knowledge Search Utils', () => {
OPENAI_API_KEY: 'test-openai-key',
})
mockNextFetchResponse({
json: {
data: [{ embedding: [0.1, 0.2, 0.3] }],
usage: { prompt_tokens: 1, total_tokens: 1 },
},
})
mockNextEmbeddingResponse()
await generateSearchEmbedding('test query', 'text-embedding-3-small')
@@ -792,12 +776,7 @@ describe('Knowledge Search Utils', () => {
it('projects verified provenance only in the model-bound embedding payload', async () => {
Object.keys(env).forEach((key) => delete (env as any)[key])
Object.assign(env, { OPENAI_API_KEY: 'test-openai-key' })
mockNextFetchResponse({
json: {
data: [{ embedding: [0.1, 0.2, 0.3] }],
usage: { prompt_tokens: 1, total_tokens: 1 },
},
})
mockNextEmbeddingResponse()
const registry = new ResolvedSecretTraceRegistry([
{ name: 'TOKEN', plaintext: 'secret-value', encryptedValue: 'encrypted-token' },
+23 -24
View File
@@ -110,17 +110,24 @@ vi.mock('@/lib/knowledge/documents/document-processor', () => ({
}),
}))
function createEmbeddingFetchMock() {
return vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: [
{ embedding: [0.1, 0.2], index: 0 },
{ embedding: [0.3, 0.4], index: 1 },
],
usage: { prompt_tokens: 2, total_tokens: 2 },
const TEST_EMBEDDING_DIMENSION = 1536
function createTestEmbedding(value: number): number[] {
return Array.from({ length: TEST_EMBEDDING_DIMENSION }, () => value)
}
function createEmbeddingResponse(values: number[]): Response {
return new Response(
JSON.stringify({
data: values.map((value, index) => ({ embedding: createTestEmbedding(value), index })),
usage: { prompt_tokens: values.length, total_tokens: values.length },
}),
})
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
}
function createEmbeddingFetchMock() {
return vi.fn().mockResolvedValue(createEmbeddingResponse([0.1, 0.3]))
}
vi.stubGlobal('fetch', createEmbeddingFetchMock())
@@ -166,6 +173,10 @@ describe('Knowledge Utils', () => {
embeddingModel: 'text-embedding-3-small',
billedAccountUserId: 'billing-user-1',
uploadedBy: null,
filename: 'file.txt',
fileUrl: 'https://example.com/file.txt',
fileSize: 10,
mimeType: 'text/plain',
},
])
/** Legacy untracked documents have exact-empty provenance. */
@@ -301,13 +312,7 @@ describe('Knowledge Utils', () => {
})
const fetchSpy = vi.mocked(fetch)
fetchSpy.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [{ embedding: [0.1, 0.2], index: 0 }],
usage: { prompt_tokens: 1, total_tokens: 1 },
}),
} as any)
fetchSpy.mockResolvedValueOnce(createEmbeddingResponse([0.1]))
await generateEmbeddings(['test text'])
@@ -331,13 +336,7 @@ describe('Knowledge Utils', () => {
})
const fetchSpy = vi.mocked(fetch)
fetchSpy.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [{ embedding: [0.1, 0.2], index: 0 }],
usage: { prompt_tokens: 1, total_tokens: 1 },
}),
} as any)
fetchSpy.mockResolvedValueOnce(createEmbeddingResponse([0.1]))
await generateEmbeddings(['test text'])
@@ -4,13 +4,26 @@
import { createMockRequest, hybridAuthMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockEmbed, mockEmbedOpenRouter, mockGetOpenRouterEmbeddingModelMetadata } = vi.hoisted(
() => ({
const {
MockEmbeddingOutputLimitError,
mockEmbed,
mockEmbedOpenRouter,
mockGetOpenRouterEmbeddingModelMetadata,
} = vi.hoisted(() => {
class MockEmbeddingOutputLimitError extends Error {
constructor(message: string) {
super(message)
this.name = 'EmbeddingOutputLimitError'
}
}
return {
MockEmbeddingOutputLimitError,
mockEmbed: vi.fn(),
mockEmbedOpenRouter: vi.fn(),
mockGetOpenRouterEmbeddingModelMetadata: vi.fn(),
})
)
}
})
vi.mock('@/lib/embeddings/openrouter-model-catalog.server', () => ({
getOpenRouterEmbeddingModelMetadata: mockGetOpenRouterEmbeddingModelMetadata,
@@ -27,6 +40,7 @@ vi.mock('@/lib/embeddings', async () => {
return {
embed: mockEmbed,
embedOpenRouter: mockEmbedOpenRouter,
EmbeddingOutputLimitError: MockEmbeddingOutputLimitError,
DEFAULT_OPENROUTER_EMBEDDING_MODEL: 'openrouter/openai/text-embedding-3-small',
findEmbeddingModelInfo: catalog.findEmbeddingModelInfo,
getModelsForProvider: catalog.getModelsForProvider,
@@ -230,6 +244,15 @@ describe('POST /api/tools/embeddings', () => {
expect((await response.json()).error).toContain('429')
})
it('returns 413 when the requested embedding output exceeds the safe aggregate limit', async () => {
mockEmbed.mockRejectedValue(
new MockEmbeddingOutputLimitError('Embedding output exceeds the safe aggregate limit')
)
const response = await post(baseBody)
expect(response.status).toBe(413)
expect((await response.json()).error).toContain('safe aggregate limit')
})
it('splits a JSON-array input into separate texts', async () => {
await post({ ...baseBody, input: '["alpha","beta"]' })
expect(mockEmbed).toHaveBeenCalledWith(['alpha', 'beta'], expect.anything())
@@ -12,6 +12,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
DEFAULT_MODEL_BY_PROVIDER,
DEFAULT_OPENROUTER_EMBEDDING_MODEL,
EmbeddingOutputLimitError,
embed,
embedOpenRouter,
findEmbeddingModelInfo,
@@ -227,6 +228,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
})
} catch (error) {
const message = getErrorMessage(error, 'Embedding generation failed')
if (error instanceof EmbeddingOutputLimitError) {
logger.warn('Embedding output exceeds safe limit', { error: message })
return NextResponse.json({ success: false, error: message }, { status: 413 })
}
logger.error('Embedding generation failed', { error: message })
return NextResponse.json({ success: false, error: message }, { status: 502 })
}
@@ -260,6 +260,45 @@ describe('v2 knowledge connector routes', () => {
expect(mocks.connectorRemoved).toHaveBeenCalledOnce()
})
it('serializes skipped-document counts in non-empty sync history', async () => {
mocks.read.mockResolvedValueOnce({
connector: {
...connector,
syncLogs: [
{
id: 'sync-log-1',
connectorId: CONNECTOR_ID,
status: 'completed',
startedAt: new Date('2026-01-03T00:00:00Z'),
completedAt: new Date('2026-01-03T00:01:00Z'),
docsAdded: 1,
docsUpdated: 2,
docsDeleted: 3,
docsUnchanged: 4,
docsSkipped: 5,
docsFailed: 6,
errorMessage: null,
},
],
},
})
const response = await getConnector(
request(
`/api/v2/knowledge/${KNOWLEDGE_BASE_ID}/connectors/${CONNECTOR_ID}?workspaceId=${WORKSPACE_ID}`
),
connectorContext
)
expect(response.status).toBe(200)
expect((await response.json()).data.syncLogs[0]).toMatchObject({
id: 'sync-log-1',
docsSkipped: 5,
startedAt: '2026-01-03T00:00:00.000Z',
completedAt: '2026-01-03T00:01:00.000Z',
})
})
it('passes source changes to application billing without an adapter resolver', async () => {
const response = await updateConnector(
request(`/api/v2/knowledge/${KNOWLEDGE_BASE_ID}/connectors/${CONNECTOR_ID}`, 'PATCH', {
@@ -44,6 +44,7 @@ interface KnowledgeConnectorSyncLogProjection {
docsUpdated: number
docsDeleted: number
docsUnchanged: number
docsSkipped: number
docsFailed: number
errorMessage: string | null
}
@@ -136,6 +136,7 @@ function makeLog(overrides: Partial<SyncLogData> & Pick<SyncLogData, 'status'>):
docsUpdated: 0,
docsDeleted: 0,
docsUnchanged: 0,
docsSkipped: 0,
docsFailed: 0,
errorMessage: null,
...overrides,
@@ -356,6 +357,30 @@ describe('SyncHistory', () => {
expect(container.textContent).toContain('No changes')
})
it('renders a skipped-only completed row as a change', () => {
const container = render(makeLog({ status: 'completed', docsSkipped: 4 }))
expect(icons(container)).toEqual(['icon-circle-check'])
expect(container.textContent).toContain('⊘4')
expect(container.textContent).not.toContain('No changes')
})
it('renders mixed sync counts as separate ordered markers', () => {
const container = render(
makeLog({
status: 'completed',
docsAdded: 2,
docsUpdated: 3,
docsDeleted: 4,
docsFailed: 5,
docsSkipped: 6,
})
)
expect(container.textContent).toContain('+2 ~3 -4 !5 ⊘6')
expect(container.textContent).not.toContain('No changes')
})
it('renders a "failed" row as an error with its message', () => {
const container = render(makeLog({ status: 'failed', errorMessage: 'token expired' }))
@@ -716,7 +716,11 @@ export function SyncHistory({ logs, isLoading }: SyncHistoryProps) {
{logs.map((log) => {
const state = getSyncLogState(log, now)
const totalChanges =
log.docsAdded + log.docsUpdated + log.docsDeleted + (log.docsFailed ?? 0)
log.docsAdded +
log.docsUpdated +
log.docsDeleted +
log.docsSkipped +
(log.docsFailed ?? 0)
return (
<div key={log.id} className='flex items-start gap-2 rounded-md px-2 py-1.5 text-xs'>
@@ -763,6 +767,16 @@ export function SyncHistory({ logs, isLoading }: SyncHistoryProps) {
<span className='text-[var(--text-error)]'>!{log.docsFailed}</span>
</>
)}
{log.docsSkipped > 0 && (
<>
{(log.docsAdded > 0 ||
log.docsUpdated > 0 ||
log.docsDeleted > 0 ||
log.docsFailed > 0) &&
' '}
<span className='text-[var(--caution)]'>⊘{log.docsSkipped}</span>
</>
)}
</>
) : (
'No changes'
@@ -1,6 +1,8 @@
/**
* @vitest-environment node
*/
import { AbortTaskRunError } from '@trigger.dev/sdk'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockAssertConnectorSyncPayload, mockExecuteSync, mockTask } = vi.hoisted(() => ({
@@ -9,7 +11,10 @@ const { mockAssertConnectorSyncPayload, mockExecuteSync, mockTask } = vi.hoisted
mockTask: vi.fn((config) => config),
}))
vi.mock('@trigger.dev/sdk', () => ({ task: mockTask }))
vi.mock('@trigger.dev/sdk', () => ({
task: mockTask,
AbortTaskRunError: class AbortTaskRunError extends Error {},
}))
vi.mock('@/lib/knowledge/connectors/queue', () => ({
assertConnectorSyncPayload: mockAssertConnectorSyncPayload,
}))
@@ -17,7 +22,10 @@ vi.mock('@/lib/knowledge/connectors/sync-engine', () => ({
executeSync: mockExecuteSync,
}))
import { executeConnectorSyncJob } from '@/background/knowledge-connector-sync'
import {
classifyConnectorSyncResult,
executeConnectorSyncJob,
} from '@/background/knowledge-connector-sync'
const BILLING_ATTRIBUTION = {
actorUserId: 'external-admin',
@@ -40,7 +48,9 @@ describe('knowledge connector sync worker', () => {
docsUpdated: 0,
docsDeleted: 0,
docsUnchanged: 0,
docsSkipped: 0,
docsFailed: 0,
processingDispatch: { requested: 0, accepted: 0, failed: 0 },
})
})
@@ -103,4 +113,164 @@ describe('knowledge connector sync worker', () => {
dispatchToken: undefined,
})
})
it('fails visibly without retrying an already-persisted partial sync', async () => {
mockAssertConnectorSyncPayload.mockReturnValue({
connectorId: 'connector-1',
requestId: 'request-1',
billingAttribution: BILLING_ATTRIBUTION,
})
mockExecuteSync.mockResolvedValue({
docsAdded: 2,
docsUpdated: 0,
docsDeleted: 0,
docsUnchanged: 0,
docsSkipped: 3,
docsFailed: 1,
processingDispatch: { requested: 2, accepted: 1, failed: 1 },
})
const run = executeConnectorSyncJob({
connectorId: 'connector-1',
requestId: 'request-1',
billingAttribution: BILLING_ATTRIBUTION,
})
await expect(run).rejects.toBeInstanceOf(AbortTaskRunError)
await expect(run).rejects.toThrow('Connector sync partially failed')
})
it('does not turn intentionally skipped source files into a task failure', () => {
expect(
classifyConnectorSyncResult({
docsAdded: 0,
docsUpdated: 0,
docsDeleted: 0,
docsUnchanged: 0,
docsSkipped: 4,
docsFailed: 0,
processingDispatch: { requested: 0, accepted: 0, failed: 0 },
})
).toBe('completed')
})
it('classifies an isolated processing dispatch failure as partial', () => {
expect(
classifyConnectorSyncResult({
docsAdded: 1,
docsUpdated: 0,
docsDeleted: 0,
docsUnchanged: 0,
docsSkipped: 0,
docsFailed: 0,
processingDispatch: { requested: 1, accepted: 0, failed: 1 },
})
).toBe('partial')
})
it('reports superseded and non-runnable jobs as skipped control flow', () => {
const baseResult = {
docsAdded: 0,
docsUpdated: 0,
docsDeleted: 0,
docsUnchanged: 0,
docsSkipped: 0,
docsFailed: 0,
processingDispatch: { requested: 0, accepted: 0, failed: 0 },
}
expect(classifyConnectorSyncResult({ ...baseResult, skipReason: 'sync_superseded' })).toBe(
'skipped'
)
expect(
classifyConnectorSyncResult({ ...baseResult, skipReason: 'connector_not_syncable' })
).toBe('skipped')
})
it('never treats a provider error that collides with a skip reason as control flow', () => {
expect(
classifyConnectorSyncResult({
docsAdded: 0,
docsUpdated: 0,
docsDeleted: 0,
docsUnchanged: 0,
docsSkipped: 0,
docsFailed: 0,
processingDispatch: { requested: 0, accepted: 0, failed: 0 },
error: 'sync_in_progress',
})
).toBe('failed')
})
it('classifies a persisted connector error as a failed task', () => {
expect(
classifyConnectorSyncResult({
docsAdded: 0,
docsUpdated: 0,
docsDeleted: 0,
docsUnchanged: 0,
docsSkipped: 0,
docsFailed: 0,
processingDispatch: { requested: 0, accepted: 0, failed: 0 },
error: 'provider unavailable',
})
).toBe('failed')
})
it('fails the Trigger run for a persisted connector error without a whole-sync retry', async () => {
mockAssertConnectorSyncPayload.mockReturnValue({
connectorId: 'connector-1',
requestId: 'request-1',
billingAttribution: BILLING_ATTRIBUTION,
})
mockExecuteSync.mockResolvedValue({
docsAdded: 0,
docsUpdated: 0,
docsDeleted: 0,
docsUnchanged: 0,
docsSkipped: 0,
docsFailed: 0,
processingDispatch: { requested: 0, accepted: 0, failed: 0 },
error: 'provider unavailable',
})
const run = executeConnectorSyncJob({
connectorId: 'connector-1',
requestId: 'request-1',
billingAttribution: BILLING_ATTRIBUTION,
})
await expect(run).rejects.toBeInstanceOf(AbortTaskRunError)
await expect(run).rejects.toThrow('Connector sync failed for connector-1: provider unavailable')
})
it('returns an explicit skipped outcome for a superseded task', async () => {
mockAssertConnectorSyncPayload.mockReturnValue({
connectorId: 'connector-1',
requestId: 'request-1',
billingAttribution: BILLING_ATTRIBUTION,
})
mockExecuteSync.mockResolvedValue({
docsAdded: 0,
docsUpdated: 0,
docsDeleted: 0,
docsUnchanged: 0,
docsSkipped: 0,
docsFailed: 0,
processingDispatch: { requested: 0, accepted: 0, failed: 0 },
skipReason: 'dispatch_superseded',
})
await expect(
executeConnectorSyncJob({
connectorId: 'connector-1',
requestId: 'request-1',
billingAttribution: BILLING_ATTRIBUTION,
})
).resolves.toMatchObject({
success: false,
outcome: 'skipped',
skipReason: 'dispatch_superseded',
})
})
})
@@ -1,14 +1,40 @@
import { createLogger } from '@sim/logger'
import { task } from '@trigger.dev/sdk'
import { AbortTaskRunError, task } from '@trigger.dev/sdk'
import {
assertConnectorSyncPayload,
type ConnectorSyncPayload,
} from '@/lib/knowledge/connectors/queue'
import { executeSync } from '@/lib/knowledge/connectors/sync-engine'
import { CONNECTOR_SYNC_MAX_DURATION_SECONDS } from '@/lib/knowledge/connectors/sync-limits'
import type { SyncResult } from '@/connectors/types'
const logger = createLogger('TriggerKnowledgeConnectorSync')
export type ConnectorSyncTaskOutcome = 'completed' | 'partial' | 'skipped' | 'failed'
/**
* Separates source-sync failures from expected queue/lock no-ops. Intentional
* source skips do not make a run partial; actual hydration, persistence, or
* processing-dispatch failures do.
*/
export function classifyConnectorSyncResult(result: SyncResult): ConnectorSyncTaskOutcome {
if (result.skipReason) return 'skipped'
if (result.error) return 'failed'
if (result.docsFailed > 0 || result.processingDispatch.failed > 0) return 'partial'
return 'completed'
}
function formatConnectorSyncFailure(
connectorId: string,
result: SyncResult,
outcome: Extract<ConnectorSyncTaskOutcome, 'partial' | 'failed'>
): string {
if (outcome === 'failed') {
return `Connector sync failed for ${connectorId}: ${result.error}`
}
return `Connector sync partially failed for ${connectorId}: ${result.docsFailed} source failures, ${result.processingDispatch.failed} dispatch failures`
}
export async function executeConnectorSyncJob(payload: unknown) {
const {
connectorId,
@@ -33,15 +59,33 @@ export async function executeConnectorSyncJob(payload: unknown) {
logger.info(`[${requestId}] Connector sync completed`, {
connectorId,
outcome: classifyConnectorSyncResult(result),
added: result.docsAdded,
updated: result.docsUpdated,
deleted: result.docsDeleted,
unchanged: result.docsUnchanged,
skipped: result.docsSkipped,
failed: result.docsFailed,
processingRequested: result.processingDispatch.requested,
processingAccepted: result.processingDispatch.accepted,
processingDispatchFailed: result.processingDispatch.failed,
})
const outcome = classifyConnectorSyncResult(result)
if (outcome === 'failed' || outcome === 'partial') {
/**
* `executeSync` has already persisted its terminal state. Source failures
* preserve the previous incremental watermark so the next connector pass
* replays them; dispatch failures remain eligible for the stuck-document
* sweep. Retrying this whole task immediately would duplicate a large
* fan-out, so fail visibly without retrying the completed transaction.
*/
throw new AbortTaskRunError(formatConnectorSyncFailure(connectorId, result, outcome))
}
return {
success: !result.error,
success: outcome === 'completed',
outcome,
connectorId,
...result,
}
+378 -12
View File
@@ -1,17 +1,24 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockAssertBillingAttributionSnapshot, mockProcessDocumentAsync, mockTask } = vi.hoisted(
() => ({
mockAssertBillingAttributionSnapshot: vi.fn(),
mockProcessDocumentAsync: vi.fn(),
mockTask: vi.fn((config) => config),
})
)
const {
mockAssertBillingAttributionSnapshot,
mockProcessDocumentAsync,
mockResolveTriggerRegion,
mockTask,
mockTrigger,
} = vi.hoisted(() => ({
mockAssertBillingAttributionSnapshot: vi.fn(),
mockProcessDocumentAsync: vi.fn(),
mockResolveTriggerRegion: vi.fn(),
mockTask: vi.fn((config) => config),
mockTrigger: vi.fn(),
}))
vi.mock('@trigger.dev/sdk', () => ({ task: mockTask }))
vi.mock('@trigger.dev/sdk', () => ({ task: mockTask, tasks: { trigger: mockTrigger } }))
vi.mock('@/lib/core/async-jobs/region', () => ({ resolveTriggerRegion: mockResolveTriggerRegion }))
vi.mock('@/lib/billing/core/billing-attribution', () => ({
assertBillingAttributionSnapshot: mockAssertBillingAttributionSnapshot,
}))
@@ -19,7 +26,17 @@ vi.mock('@/lib/knowledge/documents/service', () => ({
processDocumentAsync: mockProcessDocumentAsync,
}))
import { runDocumentProcessing } from '@/background/knowledge-processing'
import { EmbeddingQuotaExhaustedError } from '@/lib/embeddings/client'
import { EMBEDDING_QUOTA_CIRCUIT_TTL_MS } from '@/lib/embeddings/quota-circuit'
import {
PermanentDocumentProcessingError,
UsageLimitDocumentProcessingError,
} from '@/lib/knowledge/documents/document-processing-error'
import { MAX_QUOTA_CONTINUATION_ATTEMPTS } from '@/lib/knowledge/documents/processing-quota-continuation'
import {
resolveQuotaContinuationDelayMs,
runDocumentProcessing,
} from '@/background/knowledge-processing'
const BILLING_ATTRIBUTION = {
actorUserId: 'external-admin',
@@ -45,6 +62,7 @@ const BASE_PAYLOAD = {
},
processingOptions: {},
requestId: 'request-1',
processingQueuedAt: '2026-08-24T22:00:00.000Z',
}
const WORKSPACE_PAYLOAD = {
@@ -55,6 +73,16 @@ const WORKSPACE_PAYLOAD = {
billingAttribution: BILLING_ATTRIBUTION,
}
function mockQuotaExhaustion(error: EmbeddingQuotaExhaustedError): void {
mockProcessDocumentAsync.mockImplementation(async (...args: unknown[]) => {
const attemptContext = args[6] as {
scheduleQuotaContinuation?: () => Promise<Date>
}
await attemptContext.scheduleQuotaContinuation?.()
throw error
})
}
describe('knowledge processing worker', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -65,6 +93,12 @@ describe('knowledge processing worker', () => {
return value
})
mockProcessDocumentAsync.mockResolvedValue(undefined)
mockResolveTriggerRegion.mockResolvedValue('us-east-1')
mockTrigger.mockResolvedValue({ id: 'quota-continuation-run' })
})
afterEach(() => {
vi.restoreAllMocks()
})
it('rejects workspace work without attribution before document processing starts', async () => {
@@ -79,6 +113,99 @@ describe('knowledge processing worker', () => {
expect(mockProcessDocumentAsync).not.toHaveBeenCalled()
})
it('rejects an invalid durable quota retry count before processing starts', async () => {
await expect(
runDocumentProcessing({
...WORKSPACE_PAYLOAD,
quotaRetryCount: -1,
})
).rejects.toThrow('Document processing quota retry count is invalid')
expect(mockProcessDocumentAsync).not.toHaveBeenCalled()
})
it('rejects an invalid queue-generation stamp before processing starts', async () => {
await expect(
runDocumentProcessing({
...WORKSPACE_PAYLOAD,
processingQueuedAt: 'not-a-date',
})
).rejects.toThrow('Document processing queue stamp is invalid')
expect(mockProcessDocumentAsync).not.toHaveBeenCalled()
})
it('rejects a queue token that is not the request generation', async () => {
await expect(
runDocumentProcessing({
...WORKSPACE_PAYLOAD,
processingQueueToken: 'another-request',
})
).rejects.toThrow('Document processing queue token is invalid')
expect(mockProcessDocumentAsync).not.toHaveBeenCalled()
})
it('rejects a new queue token without its canonical queue stamp', async () => {
const { processingQueuedAt: _processingQueuedAt, ...payloadWithoutStamp } = WORKSPACE_PAYLOAD
await expect(
runDocumentProcessing({
...payloadWithoutStamp,
processingQueueToken: 'request-1',
})
).rejects.toThrow('Document processing payload is missing its queue stamp')
expect(mockProcessDocumentAsync).not.toHaveBeenCalled()
})
it('rejects a new dispatch charge marker without a queue token', async () => {
await expect(
runDocumentProcessing({
...WORKSPACE_PAYLOAD,
chargedAtDispatch: true,
})
).rejects.toThrow('Document processing dispatch charge marker requires a queue token')
expect(mockProcessDocumentAsync).not.toHaveBeenCalled()
})
it('accepts a literal pre-rollout staging payload without synthesizing a generation stamp', async () => {
const { processingQueuedAt: _processingQueuedAt, ...stagingPayload } = WORKSPACE_PAYLOAD
await runDocumentProcessing(stagingPayload)
expect(mockProcessDocumentAsync).toHaveBeenLastCalledWith(
'knowledge-base-1',
'document-1',
BASE_PAYLOAD.docData,
{},
expect.objectContaining({ billingScope: 'workspace' }),
'request-1',
expect.objectContaining({
chargedAtDispatch: false,
scheduleQuotaContinuation: expect.any(Function),
})
)
})
it('propagates a new queue token while accepting legacy queuedAt-only payloads', async () => {
await runDocumentProcessing({
...WORKSPACE_PAYLOAD,
processingQueueToken: 'request-1',
chargedAtDispatch: false,
})
expect(mockProcessDocumentAsync).toHaveBeenLastCalledWith(
expect.any(String),
expect.any(String),
expect.any(Object),
expect.any(Object),
expect.any(Object),
'request-1',
expect.objectContaining({
chargedAtDispatch: false,
processingQueueToken: 'request-1',
processingQueuedAt: new Date(BASE_PAYLOAD.processingQueuedAt),
})
)
})
it('preserves the validated actor and payer snapshot through serialization', async () => {
await runDocumentProcessing(structuredClone(WORKSPACE_PAYLOAD))
@@ -93,7 +220,12 @@ describe('knowledge processing worker', () => {
workspaceId: 'workspace-1',
billingAttribution: BILLING_ATTRIBUTION,
},
BASE_PAYLOAD.requestId
BASE_PAYLOAD.requestId,
expect.objectContaining({
chargedAtDispatch: true,
processingQueuedAt: new Date(BASE_PAYLOAD.processingQueuedAt),
scheduleQuotaContinuation: expect.any(Function),
})
)
})
@@ -135,7 +267,228 @@ describe('knowledge processing worker', () => {
actorUserId: 'legacy-owner',
workspaceId: null,
},
BASE_PAYLOAD.requestId
BASE_PAYLOAD.requestId,
expect.objectContaining({
chargedAtDispatch: true,
processingQueuedAt: new Date(BASE_PAYLOAD.processingQueuedAt),
scheduleQuotaContinuation: expect.any(Function),
})
)
})
it('reports elapsed processing time rather than an epoch timestamp', async () => {
vi.spyOn(Date, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(1_125)
const result = await runDocumentProcessing({
...BASE_PAYLOAD,
billingScope: 'non-workspace',
actorUserId: 'legacy-owner',
workspaceId: null,
})
expect(result.processingTime).toBe(125)
})
it('returns a controlled terminal result for permanent document input failures', async () => {
mockProcessDocumentAsync.mockRejectedValue(
new PermanentDocumentProcessingError(
'archive_safety_limit',
'This file expands beyond the safe processing limit.'
)
)
await expect(
runDocumentProcessing({
...BASE_PAYLOAD,
billingScope: 'non-workspace',
actorUserId: 'legacy-owner',
workspaceId: null,
})
).resolves.toMatchObject({
success: false,
outcome: 'permanent_failure',
code: 'archive_safety_limit',
error: 'This file expands beyond the safe processing limit.',
})
})
it('reports a mutable usage-limit outcome without requesting an immediate retry', async () => {
mockProcessDocumentAsync.mockRejectedValue(
new UsageLimitDocumentProcessingError('Usage limit exceeded. Upgrade to continue.')
)
await expect(
runDocumentProcessing({
...BASE_PAYLOAD,
billingScope: 'non-workspace',
actorUserId: 'legacy-owner',
workspaceId: null,
})
).resolves.toMatchObject({
success: false,
outcome: 'usage_limit',
error: 'Usage limit exceeded. Upgrade to continue.',
})
})
it('preserves normal retries for transient failures', async () => {
const transientError = new Error('Database connection timed out')
mockProcessDocumentAsync.mockRejectedValue(transientError)
await expect(
runDocumentProcessing({
...BASE_PAYLOAD,
billingScope: 'non-workspace',
actorUserId: 'legacy-owner',
workspaceId: null,
})
).rejects.toBe(transientError)
})
it('durably continues quota exhaustion beyond the task attempt budget', async () => {
vi.spyOn(Date, 'now').mockReturnValue(1_000)
const quotaError = new EmbeddingQuotaExhaustedError('openai')
mockQuotaExhaustion(quotaError)
await expect(
runDocumentProcessing({
...BASE_PAYLOAD,
billingScope: 'non-workspace',
actorUserId: 'legacy-owner',
workspaceId: null,
})
).resolves.toMatchObject({ success: false, outcome: 'quota_deferred' })
expect(mockTrigger).toHaveBeenCalledWith(
'knowledge-process-document',
expect.objectContaining({
documentId: 'document-1',
requestId: 'request-1',
processingQueuedAt: BASE_PAYLOAD.processingQueuedAt,
quotaRetryCount: 1,
}),
expect.objectContaining({
delay: expect.any(Date),
idempotencyKey: 'knowledge-quota-document-1-request-1-1',
region: 'us-east-1',
})
)
const delay = mockTrigger.mock.calls[0]?.[2]?.delay as Date
expect(delay.getTime()).toBeGreaterThanOrEqual(1_000 + EMBEDDING_QUOTA_CIRCUIT_TTL_MS * 0.8)
expect(delay.getTime()).toBeLessThanOrEqual(1_000 + EMBEDDING_QUOTA_CIRCUIT_TTL_MS * 1.2)
})
it('ends a quota chain after the bounded continuation horizon', async () => {
mockQuotaExhaustion(new EmbeddingQuotaExhaustedError('openai'))
await expect(
runDocumentProcessing({
...BASE_PAYLOAD,
quotaRetryCount: MAX_QUOTA_CONTINUATION_ATTEMPTS,
billingScope: 'non-workspace',
actorUserId: 'legacy-owner',
workspaceId: null,
})
).resolves.toMatchObject({ success: false, outcome: 'quota_exhausted' })
expect(mockTrigger).not.toHaveBeenCalled()
expect(mockProcessDocumentAsync).toHaveBeenCalledWith(
expect.any(String),
expect.any(String),
expect.any(Object),
expect.any(Object),
expect.any(Object),
expect.any(String),
expect.objectContaining({ quotaContinuationExhausted: true })
)
})
it('keeps the task failed when the durable continuation handoff fails', async () => {
mockQuotaExhaustion(new EmbeddingQuotaExhaustedError('openai'))
const dispatchError = new Error('Trigger dispatch unavailable')
mockTrigger.mockRejectedValue(dispatchError)
await expect(
runDocumentProcessing({
...BASE_PAYLOAD,
billingScope: 'non-workspace',
actorUserId: 'legacy-owner',
workspaceId: null,
})
).rejects.toBe(dispatchError)
})
it('continues an existing quota chain with the same indexing pass identity', async () => {
mockQuotaExhaustion(new EmbeddingQuotaExhaustedError('openai'))
await runDocumentProcessing({
...BASE_PAYLOAD,
quotaRetryCount: 3,
billingScope: 'non-workspace',
actorUserId: 'legacy-owner',
workspaceId: null,
})
expect(mockTrigger).toHaveBeenCalledWith(
'knowledge-process-document',
expect.objectContaining({ requestId: 'request-1', quotaRetryCount: 4 }),
expect.objectContaining({
idempotencyKey: 'knowledge-quota-document-1-request-1-4',
})
)
})
it('does not refund the original dispatch charge again on a task retry', async () => {
await runDocumentProcessing(
{
...BASE_PAYLOAD,
billingScope: 'non-workspace',
actorUserId: 'legacy-owner',
workspaceId: null,
},
2
)
expect(mockProcessDocumentAsync).toHaveBeenCalledWith(
'knowledge-base-1',
'document-1',
BASE_PAYLOAD.docData,
{},
{
billingScope: 'non-workspace',
actorUserId: 'legacy-owner',
workspaceId: null,
},
BASE_PAYLOAD.requestId,
expect.objectContaining({
chargedAtDispatch: false,
processingQueuedAt: new Date(BASE_PAYLOAD.processingQueuedAt),
scheduleQuotaContinuation: expect.any(Function),
})
)
})
it('preserves the queue generation without refunding a quota continuation run', async () => {
await runDocumentProcessing({
...BASE_PAYLOAD,
quotaRetryCount: 3,
billingScope: 'non-workspace',
actorUserId: 'legacy-owner',
workspaceId: null,
})
expect(mockProcessDocumentAsync).toHaveBeenLastCalledWith(
'knowledge-base-1',
'document-1',
BASE_PAYLOAD.docData,
{},
expect.objectContaining({ billingScope: 'non-workspace' }),
BASE_PAYLOAD.requestId,
expect.objectContaining({
chargedAtDispatch: false,
processingQueuedAt: new Date(BASE_PAYLOAD.processingQueuedAt),
scheduleQuotaContinuation: expect.any(Function),
})
)
})
})
@@ -152,4 +505,17 @@ describe('knowledge-process-document task configuration', () => {
expect(processDocument.retry?.outOfMemory?.machine).toBe('large-2x')
})
it('backs durable quota continuations off to a bounded polling interval', () => {
const first = resolveQuotaContinuationDelayMs(1)
const second = resolveQuotaContinuationDelayMs(2)
const capped = resolveQuotaContinuationDelayMs(Number.MAX_SAFE_INTEGER)
expect(first).toBeGreaterThanOrEqual(EMBEDDING_QUOTA_CIRCUIT_TTL_MS * 0.8)
expect(first).toBeLessThanOrEqual(EMBEDDING_QUOTA_CIRCUIT_TTL_MS * 1.2)
expect(second).toBeGreaterThanOrEqual(EMBEDDING_QUOTA_CIRCUIT_TTL_MS * 2 * 0.8)
expect(second).toBeLessThanOrEqual(EMBEDDING_QUOTA_CIRCUIT_TTL_MS * 2 * 1.2)
expect(capped).toBeGreaterThanOrEqual(6 * 60 * 60 * 1000 * 0.8)
expect(capped).toBeLessThanOrEqual(6 * 60 * 60 * 1000)
})
})
+86 -10
View File
@@ -1,16 +1,32 @@
import { createLogger } from '@sim/logger'
import { task } from '@trigger.dev/sdk'
import { env, envNumber } from '@/lib/core/config/env'
import { EMBEDDING_QUOTA_EXHAUSTED_MESSAGE, isEmbeddingQuotaExhaustion } from '@/lib/embeddings'
import {
isPermanentDocumentProcessingError,
isUsageLimitDocumentProcessingError,
} from '@/lib/knowledge/documents/document-processing-error'
import {
assertDocumentProcessingPayload,
type DocumentProcessingBillingContext,
type DocumentProcessingPayload,
} from '@/lib/knowledge/documents/processing-payload'
import {
canScheduleDocumentProcessingQuotaContinuation,
MAX_QUOTA_CONTINUATION_ATTEMPTS,
resolveQuotaContinuationDelayMs,
scheduleDocumentProcessingQuotaContinuation,
} from '@/lib/knowledge/documents/processing-quota-continuation'
import { processDocumentAsync } from '@/lib/knowledge/documents/service'
const logger = createLogger('TriggerKnowledgeProcessing')
export { resolveQuotaContinuationDelayMs }
export async function runDocumentProcessing(rawPayload: DocumentProcessingPayload) {
export async function runDocumentProcessing(
rawPayload: DocumentProcessingPayload,
attemptNumber = 1
) {
const startedAt = Date.now()
const payload = assertDocumentProcessingPayload(rawPayload)
const { knowledgeBaseId, documentId, docData, processingOptions, requestId } = payload
const billingContext: DocumentProcessingBillingContext =
@@ -26,6 +42,7 @@ export async function runDocumentProcessing(rawPayload: DocumentProcessingPayloa
actorUserId: payload.actorUserId,
workspaceId: null,
}
const canScheduleQuotaContinuation = canScheduleDocumentProcessingQuotaContinuation(payload)
logger.info(`[${requestId}] Starting Trigger.dev processing for document: ${docData.filename}`)
@@ -36,7 +53,24 @@ export async function runDocumentProcessing(rawPayload: DocumentProcessingPayloa
docData,
processingOptions,
billingContext,
requestId
requestId,
{
chargedAtDispatch:
(payload.chargedAtDispatch ?? payload.processingQueuedAt !== undefined) &&
attemptNumber === 1 &&
payload.quotaRetryCount === undefined,
...(payload.processingQueueToken
? { processingQueueToken: payload.processingQueueToken }
: {}),
...(payload.processingQueuedAt
? { processingQueuedAt: new Date(payload.processingQueuedAt) }
: {}),
...(canScheduleQuotaContinuation
? {
scheduleQuotaContinuation: () => scheduleDocumentProcessingQuotaContinuation(payload),
}
: { quotaContinuationExhausted: true }),
}
)
logger.info(`[${requestId}] Successfully processed document: ${docData.filename}`)
@@ -45,9 +79,54 @@ export async function runDocumentProcessing(rawPayload: DocumentProcessingPayloa
success: true,
documentId,
filename: docData.filename,
processingTime: Date.now(),
processingTime: Date.now() - startedAt,
}
} catch (error) {
if (isUsageLimitDocumentProcessingError(error)) {
logger.warn(`[${requestId}] Document processing is blocked by the current usage limit`, {
filename: docData.filename,
})
return {
success: false,
outcome: 'usage_limit' as const,
documentId,
filename: docData.filename,
error: error.message,
processingTime: Date.now() - startedAt,
}
}
if (isEmbeddingQuotaExhaustion(error)) {
const outcome = canScheduleQuotaContinuation ? 'quota_deferred' : 'quota_exhausted'
logger.warn(`[${requestId}] Embedding quota is exhausted`, {
filename: docData.filename,
quotaRetryCount: payload.quotaRetryCount ?? 0,
continuationLimit: MAX_QUOTA_CONTINUATION_ATTEMPTS,
outcome,
})
return {
success: false,
outcome,
documentId,
filename: docData.filename,
error: EMBEDDING_QUOTA_EXHAUSTED_MESSAGE,
processingTime: Date.now() - startedAt,
}
}
if (isPermanentDocumentProcessingError(error)) {
logger.warn(`[${requestId}] Document cannot be processed without changing its content`, {
code: error.code,
filename: docData.filename,
})
return {
success: false,
outcome: 'permanent_failure' as const,
documentId,
filename: docData.filename,
code: error.code,
error: error.message,
processingTime: Date.now() - startedAt,
}
}
logger.error(`[${requestId}] Failed to process document: ${docData.filename}`, error)
throw error
}
@@ -64,12 +143,8 @@ export const processDocument = task({
maxTimeoutInMs: envNumber(env.KB_CONFIG_MAX_TIMEOUT, 10000),
/**
* `maxAttempts` does not cover an out-of-memory kill — Trigger.dev retries
* `TASK_PROCESS_OOM_KILLED` only when a larger preset is named here. Eleven
* documents were killed in one afternoon and every one recorded
* `attempt_count = 1`, so each was left `failed` with no retry at all. The
* escalation is a safety net, not the fix: the workbook parser's allocation
* no longer scales with a sheet's declared range, and fleet p99 memory is
* 691 MB against this machine's 8 GB.
* `TASK_PROCESS_OOM_KILLED` only when a larger preset is named here. The
* escalation is a safety net after parser allocations have been bounded.
*/
outOfMemory: { machine: 'large-2x' },
},
@@ -77,5 +152,6 @@ export const processDocument = task({
concurrencyLimit: envNumber(env.KB_CONFIG_CONCURRENCY_LIMIT, 20),
name: 'document-processing-queue',
},
run: runDocumentProcessing,
run: (payload: DocumentProcessingPayload, { ctx }) =>
runDocumentProcessing(payload, ctx.attempt.number),
})
+359 -31
View File
@@ -3,36 +3,33 @@
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockFetchWithRetry } = vi.hoisted(() => ({ mockFetchWithRetry: vi.fn() }))
vi.mock('@/lib/knowledge/documents/utils', () => ({
fetchWithRetry: mockFetchWithRetry,
VALIDATE_RETRY_OPTIONS: {},
}))
vi.mock('@/components/icons', () => ({ FirefliesIcon: () => null }))
import { firefliesConnector } from '@/connectors/fireflies/fireflies'
beforeEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
interface GraphQLCall {
query: string
variables: Record<string, unknown>
}
/** Replays the given GraphQL bodies in order and records what was sent. */
function mockGraphQL(responses: { status?: number; body: unknown }[]) {
function mockGraphQL(
responses: { status?: number; body: unknown; headers?: Record<string, string> }[]
) {
const calls: GraphQLCall[] = []
let index = 0
mockFetchWithRetry.mockImplementation(async (_url: string, options: RequestInit) => {
calls.push(JSON.parse(String(options.body)))
const fetchMock = vi.fn(async (_url: string | URL | Request, options?: RequestInit) => {
calls.push(JSON.parse(String(options?.body)))
const route = responses[Math.min(index++, responses.length - 1)]
const status = route.status ?? 200
return {
ok: status >= 200 && status < 300,
status,
json: async () => route.body,
text: async () => JSON.stringify(route.body),
} as unknown as Response
return new Response(JSON.stringify(route.body), { status, headers: route.headers })
})
vi.stubGlobal('fetch', fetchMock)
return calls
}
@@ -83,17 +80,54 @@ describe('fireflies listDocuments', () => {
expect(calls[0].query).not.toContain('50')
})
it('leaves listingCapped unset when the source is genuinely exhausted', async () => {
it.each([
[
{ is_live: true, meeting_info: { summary_status: 'processing' } },
{ is_live: false, meeting_info: { summary_status: 'processing' } },
],
[
{ is_live: false, meeting_info: { summary_status: 'processing' } },
{ is_live: false, meeting_info: { summary_status: 'processed' } },
],
])('changes the listing hash when transcript lifecycle state changes', async (before, after) => {
const calls = mockGraphQL([
{ body: { data: { transcripts: [transcript('t0', before)] } } },
{ body: { data: { transcripts: [transcript('t0', after)] } } },
])
const first = await firefliesConnector.listDocuments('key', {}, undefined, {})
const second = await firefliesConnector.listDocuments('key', {}, undefined, {})
expect(first.documents[0].contentHash).not.toBe(second.documents[0].contentHash)
expect(calls[0].query).toContain('is_live')
expect(calls[0].query).toContain('summary_status')
})
it.each(['-1', '1.5', 'Infinity', '9007199254740992', 'opaque'])(
'rejects invalid pagination cursor %s before calling Fireflies',
async (cursor) => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await expect(firefliesConnector.listDocuments('key', {}, cursor, {})).rejects.toThrow(
'Invalid Fireflies connector pagination cursor'
)
expect(fetchMock).not.toHaveBeenCalled()
}
)
it('marks offset pagination unsafe for deletion reconciliation even when exhausted', async () => {
mockGraphQL([page(3)])
const syncContext: Record<string, unknown> = {}
const result = await firefliesConnector.listDocuments('key', {}, undefined, syncContext)
expect(result.hasMore).toBe(false)
expect(result.reconciliationSafe).toBe(false)
expect(syncContext.listingCapped).toBeUndefined()
})
it('leaves listingCapped unset when maxTranscripts lands exactly on exhaustion', async () => {
it('keeps deletion reconciliation disabled when maxTranscripts lands on exhaustion', async () => {
mockGraphQL([page(3)])
const syncContext: Record<string, unknown> = {}
@@ -105,6 +139,7 @@ describe('fireflies listDocuments', () => {
)
expect(result.documents).toHaveLength(3)
expect(result.reconciliationSafe).toBe(false)
expect(syncContext.listingCapped).toBeUndefined()
})
@@ -124,48 +159,227 @@ describe('fireflies listDocuments', () => {
expect(syncContext.listingCapped).toBe(true)
})
it.each(['1.5', 'Infinity'])(
'rejects invalid maxTranscripts %s before calling Fireflies',
async (maxTranscripts) => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await expect(
firefliesConnector.listDocuments('key', { maxTranscripts }, undefined, {})
).rejects.toThrow(/positive safe integer/)
expect(fetchMock).not.toHaveBeenCalled()
}
)
it('throws on a GraphQL errors[] payload rather than reporting an empty listing', async () => {
mockGraphQL([
{ body: { data: {}, errors: [{ message: 'Rate limited', code: 'too_many_requests' }] } },
{ body: { data: {}, errors: [{ message: 'Invalid input', code: 'invalid_arguments' }] } },
])
await expect(firefliesConnector.listDocuments('key', {}, undefined, {})).rejects.toThrow(
/too_many_requests/
/invalid_arguments/
)
})
it('throws rather than reporting an empty listing when a 200 body is unreadable', async () => {
mockFetchWithRetry.mockResolvedValue({
ok: true,
status: 200,
json: async () => {
throw new SyntaxError('Unexpected token < in JSON at position 0')
},
text: async () => '<html>gateway</html>',
} as unknown as Response)
vi.useFakeTimers()
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('<html>gateway</html>'))
)
const syncContext: Record<string, unknown> = {}
await expect(
const pending = expect(
firefliesConnector.listDocuments('key', {}, undefined, syncContext)
).rejects.toThrow(/malformed/i)
await vi.runAllTimersAsync()
await pending
})
it('throws rather than reporting an empty listing when a 200 carries no data', async () => {
vi.useFakeTimers()
mockGraphQL([{ body: {} }])
const pending = expect(
firefliesConnector.listDocuments('key', {}, undefined, {})
).rejects.toThrow(/malformed/i)
await vi.runAllTimersAsync()
await pending
})
it('throws rather than reporting an empty listing when transcripts are missing', async () => {
mockGraphQL([{ body: { data: {} } }])
await expect(firefliesConnector.listDocuments('key', {}, undefined, {})).rejects.toThrow(
/malformed/i
'Fireflies API returned malformed transcript-list data'
)
})
it('surfaces the errors[] message on a non-2xx response', async () => {
it('rejects malformed transcript rows instead of silently filtering them', async () => {
mockGraphQL([{ body: { data: { transcripts: [{}] } } }])
await expect(firefliesConnector.listDocuments('key', {}, undefined, {})).rejects.toThrow(
'Fireflies API returned malformed transcript metadata'
)
})
it('retries one malformed page without discarding the sync', async () => {
vi.useFakeTimers()
mockGraphQL([{ body: {} }, page(2)])
const pending = firefliesConnector.listDocuments('key', {}, undefined, {})
await vi.runAllTimersAsync()
const result = await pending
expect(result.documents).toHaveLength(2)
expect(global.fetch).toHaveBeenCalledTimes(2)
})
it.each([
['HTTP', { status: 500, body: { message: 'Temporary upstream failure' } }],
[
'GraphQL',
{
body: {
errors: [{ message: 'Temporary resolver failure', extensions: { status: 500 } }],
},
},
],
])('retries a transient %s 5xx response', async (_kind, failure) => {
vi.useFakeTimers()
mockGraphQL([failure, page(2)])
const pending = firefliesConnector.listDocuments('key', {}, undefined, {})
await vi.runAllTimersAsync()
const result = await pending
expect(result.documents).toHaveLength(2)
expect(global.fetch).toHaveBeenCalledTimes(2)
})
it.each([
[520, 60],
[522, 120],
])('respects Retry-After and retries Cloudflare %i', async (status, retryAfterSeconds) => {
vi.useFakeTimers()
mockGraphQL([
{
status,
body: { diagnostic: 'temporary edge failure' },
headers: { 'retry-after': String(retryAfterSeconds) },
},
page(2),
])
const pending = firefliesConnector.listDocuments('key', {}, undefined, {})
await vi.advanceTimersByTimeAsync(retryAfterSeconds * 1000 - 1)
expect(global.fetch).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
const result = await pending
expect(result.documents).toHaveLength(2)
expect(global.fetch).toHaveBeenCalledTimes(2)
})
it('respects Retry-After when a retryable error body exceeds the diagnostic limit', async () => {
vi.useFakeTimers()
const retryAfterSeconds = 60
mockGraphQL([
{
status: 503,
body: { diagnostic: 'body is not materialized' },
headers: {
'content-length': String(16 * 1024 * 1024 + 1),
'retry-after': String(retryAfterSeconds),
},
},
page(2),
])
const pending = firefliesConnector.listDocuments('key', {}, undefined, {})
await vi.advanceTimersByTimeAsync(retryAfterSeconds * 1000 - 1)
expect(global.fetch).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
const result = await pending
expect(result.documents).toHaveLength(2)
expect(global.fetch).toHaveBeenCalledTimes(2)
})
it('parses an HTTP 429 GraphQL retry timestamp before retrying', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
mockGraphQL([
{
status: 429,
body: {
errors: [
{
message: 'Rate limited',
code: 'too_many_requests',
extensions: { status: 429, metadata: { retryAfter: Date.now() + 60_000 } },
},
],
},
},
page(1),
])
const pending = firefliesConnector.listDocuments('key', {}, undefined, {})
await vi.advanceTimersByTimeAsync(60_000)
await expect(pending).resolves.toMatchObject({ documents: [{ externalId: 't0' }] })
expect(global.fetch).toHaveBeenCalledTimes(2)
})
it('surfaces a validated errors[] code without the provider message', async () => {
mockGraphQL([
{ status: 403, body: { errors: [{ message: 'Upgrade required', code: 'paid_required' }] } },
])
await expect(firefliesConnector.listDocuments('key', {}, undefined, {})).rejects.toThrow(
/Upgrade required/
/paid_required/
)
expect(global.fetch).toHaveBeenCalledTimes(1)
})
it('bounds non-retryable GraphQL error responses', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response('x'.repeat(17 * 1024 * 1024), { status: 403, statusText: 'Forbidden' })
)
)
await expect(firefliesConnector.listDocuments('key', {}, undefined, {})).rejects.toThrow(
/exceeded the diagnostic limit/i
)
expect(global.fetch).toHaveBeenCalledTimes(1)
})
})
describe('fireflies validateConfig', () => {
it.each(['1.5', 'Infinity'])(
'rejects invalid maxTranscripts %s without an API request',
async (maxTranscripts) => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await expect(firefliesConnector.validateConfig!('key', { maxTranscripts })).resolves.toEqual({
valid: false,
error: 'Max transcripts must be a positive safe integer, or 0 for unlimited',
})
expect(fetchMock).not.toHaveBeenCalled()
}
)
it('accepts a valid integer maxTranscripts', async () => {
mockGraphQL([{ body: { data: { user: { user_id: 'user-1' } } } }])
await expect(
firefliesConnector.validateConfig!('key', { maxTranscripts: '25' })
).resolves.toEqual({ valid: true })
})
})
@@ -174,6 +388,17 @@ describe('fireflies getDocument', () => {
vi.clearAllMocks()
})
it.each([null, {}, transcript('different')])(
'rejects malformed or mismatched successful transcript metadata',
async (value) => {
mockGraphQL([{ body: { data: { transcript: value } } }])
await expect(firefliesConnector.getDocument('key', {}, 't0')).rejects.toThrow(
'Fireflies API returned malformed transcript metadata'
)
}
)
it('declares the transcript id as String! and reuses the stub contentHash', async () => {
const calls = mockGraphQL([
page(1),
@@ -211,6 +436,109 @@ describe('fireflies getDocument', () => {
expect(full?.metadata?.duration).toBe(45)
})
it('formats the documented string keyword shape and tolerates legacy arrays', async () => {
mockGraphQL([
{
body: {
data: { transcript: transcript('t0', { summary: { keywords: 'alpha, beta' } }) },
},
},
{
body: {
data: { transcript: transcript('t1', { summary: { keywords: ['alpha', 'beta'] } }) },
},
},
])
const documented = await firefliesConnector.getDocument('key', {}, 't0')
const legacy = await firefliesConnector.getDocument('key', {}, 't1')
expect(documented?.content).toContain('Keywords: alpha, beta')
expect(legacy?.content).toContain('Keywords: alpha, beta')
})
it('surfaces extracted transcript content beyond its byte budget as skipped', async () => {
mockGraphQL([
{
body: {
data: {
transcript: transcript('t0', {
sentences: [{ speaker_name: 'Ada', text: 'x'.repeat(9 * 1024 * 1024) }],
}),
},
},
},
])
const result = await firefliesConnector.getDocument('key', {}, 't0')
expect(result).toMatchObject({ content: '', contentDeferred: false })
expect(result?.skippedReason).toContain('8MB')
})
it('admits a hydration envelope exactly at the wire limit before applying the content cap', async () => {
const maxResponseBytes = 16 * 1024 * 1024
const responseBody = (text: string) => ({
data: {
transcript: transcript('boundary', {
sentences: [{ speaker_name: 'Ada', text }],
}),
},
})
const emptyEnvelopeBytes = Buffer.byteLength(JSON.stringify(responseBody('')), 'utf8')
const serialized = JSON.stringify(
responseBody('x'.repeat(maxResponseBytes - emptyEnvelopeBytes))
)
expect(Buffer.byteLength(serialized, 'utf8')).toBe(maxResponseBytes)
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(serialized))
)
const result = await firefliesConnector.getDocument('key', {}, 'boundary')
expect(result).toMatchObject({ content: '', contentDeferred: false })
expect(result?.skippedReason).toContain('8MB extracted-content limit')
})
it('surfaces JSON escape expansion beyond the wire cap as a visible skip', async () => {
const escapedText = '\u0000'.repeat(3 * 1024 * 1024)
const responseBody = JSON.stringify({
data: {
transcript: transcript('escaped', {
sentences: [{ speaker_name: 'Ada', text: escapedText }],
}),
},
})
expect(Buffer.byteLength(escapedText, 'utf8')).toBeLessThan(8 * 1024 * 1024)
expect(Buffer.byteLength(responseBody, 'utf8')).toBeGreaterThan(16 * 1024 * 1024)
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(responseBody))
)
const result = await firefliesConnector.getDocument('key', {}, 'escaped')
expect(result).toMatchObject({
externalId: 'escaped',
content: '',
contentDeferred: false,
skippedReason:
'Transcript response exceeds the 16MB safe hydration limit and was not indexed',
})
})
it('does not convert an oversized provider error response into a skipped transcript', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('x'.repeat(17 * 1024 * 1024), { status: 403 }))
)
await expect(firefliesConnector.getDocument('key', {}, 'provider-error')).rejects.toThrow(
/HTTP error: 403/
)
})
it('falls back to organizer_email when the deprecated host_email is absent', async () => {
mockGraphQL([{ body: { data: { transcript: transcript('t0') } } }])
+317 -93
View File
@@ -1,14 +1,54 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
import { isPlainRecord } from '@sim/utils/object'
import { isPayloadSizeLimitError, readResponseTextWithLimit } from '@/lib/core/utils/stream-limits'
import {
isRetryableError,
type RetryOptions,
resolveRetryDelayMs,
retryWithExponentialBackoff,
VALIDATE_RETRY_OPTIONS,
} from '@/lib/knowledge/documents/utils'
import { firefliesConnectorMeta } from '@/connectors/fireflies/meta'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import { parseTagDate } from '@/connectors/utils'
import {
ConnectorFileTooLargeError,
computeContentHash,
markSkipped,
parseOptionalUnlimitedSafeInteger,
parseTagDate,
} from '@/connectors/utils'
const logger = createLogger('FirefliesConnector')
const FIREFLIES_GRAPHQL_URL = 'https://api.fireflies.ai/graphql'
const TRANSCRIPTS_PER_PAGE = 50
const FIREFLIES_MAX_RESPONSE_BYTES = 16 * 1024 * 1024
const FIREFLIES_MAX_EXTRACTED_CONTENT_BYTES = 8 * 1024 * 1024
const FIREFLIES_DEFAULT_MAX_RETRY_DELAY_MS = 120_000
const FIREFLIES_DEFAULT_MAX_RETRIES = 2
const FIREFLIES_EXTRACTED_CONTENT_SKIP_REASON =
'Transcript exceeds the 8MB extracted-content limit and was not indexed'
const FIREFLIES_RESPONSE_SKIP_REASON =
'Transcript response exceeds the 16MB safe hydration limit and was not indexed'
const MAX_TRANSCRIPTS_VALIDATION_ERROR =
'Max transcripts must be a positive safe integer, or 0 for unlimited'
function parseMaxTranscripts(value: unknown): number {
return parseOptionalUnlimitedSafeInteger(value, MAX_TRANSCRIPTS_VALIDATION_ERROR)
}
function parsePaginationCursor(cursor?: string): number {
if (cursor === undefined) return 0
if (!/^\d+$/.test(cursor)) {
throw new Error('Invalid Fireflies connector pagination cursor')
}
const parsed = Number(cursor)
if (!Number.isSafeInteger(parsed)) {
throw new Error('Invalid Fireflies connector pagination cursor')
}
return parsed
}
interface FirefliesTranscript {
id: string
@@ -22,15 +62,42 @@ interface FirefliesTranscript {
participants?: string[]
transcript_url?: string
speakers?: { name: string }[]
is_live?: boolean
meeting_info?: {
summary_status?: string
}
sentences?: { speaker_name: string; text: string }[]
summary?: {
keywords?: string[]
/** Fireflies documents a string; tolerate the historical array shape defensively. */
keywords?: string | string[]
action_items?: string
overview?: string
short_summary?: string
}
}
function isFirefliesTranscript(value: unknown): value is FirefliesTranscript {
return (
isPlainRecord(value) &&
typeof value.id === 'string' &&
value.id.length > 0 &&
typeof value.title === 'string' &&
typeof value.date === 'number' &&
Number.isFinite(value.date) &&
typeof value.duration === 'number' &&
Number.isFinite(value.duration)
)
}
interface FirefliesGraphQLError {
message?: string
code?: string
extensions?: {
status?: number
metadata?: { retryAfter?: number }
}
}
/**
* Carries the Fireflies GraphQL error `code` so callers can tell a genuinely missing
* object (`object_not_found`) from a transient fault (`too_many_requests`, 5xx).
@@ -38,13 +105,40 @@ interface FirefliesTranscript {
class FirefliesApiError extends Error {
constructor(
message: string,
readonly code?: string
readonly code?: string,
readonly status?: number,
readonly retryAfterMs?: number
) {
super(message)
this.name = 'FirefliesApiError'
}
}
class FirefliesMalformedResponseError extends Error {
constructor(status: number) {
super(`Fireflies API returned a malformed response with no data (HTTP ${status})`)
this.name = 'FirefliesMalformedResponseError'
}
}
const FIREFLIES_RETRYABLE_ERROR_CODES = new Set([
'too_many_requests',
'request_timeout',
'invariant_violation',
])
function isRetryableFirefliesError(error: unknown): boolean {
if (error instanceof FirefliesMalformedResponseError) return true
if (error instanceof FirefliesApiError) {
return (
(error.status !== undefined && error.status >= 500 && error.status <= 599) ||
Boolean(error.code && FIREFLIES_RETRYABLE_ERROR_CODES.has(error.code)) ||
isRetryableError(error)
)
}
return isRetryableError(error)
}
/**
* Executes a GraphQL query against the Fireflies API.
*/
@@ -52,109 +146,173 @@ async function firefliesGraphQL(
accessToken: string,
query: string,
variables: Record<string, unknown> = {},
retryOptions?: Parameters<typeof fetchWithRetry>[2]
retryOptions?: RetryOptions
): Promise<Record<string, unknown>> {
const response = await fetchWithRetry(
FIREFLIES_GRAPHQL_URL,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ query, variables }),
return retryWithExponentialBackoff(
async () => {
/** One retry layer owns transport, HTTP, and GraphQL semantic failures. */
const response = await fetch(FIREFLIES_GRAPHQL_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ query, variables }),
})
/**
* Fireflies reports failures as an `errors` array in the body, and does so on
* non-2xx responses too (`object_not_found` → 404, `too_many_requests` → 429,
* `paid_required` → 403). Read the body first so the caller sees the actual
* reason instead of a bare status code.
*/
let rawBody: string
try {
rawBody = await readResponseTextWithLimit(response, {
maxBytes: FIREFLIES_MAX_RESPONSE_BYTES,
label: 'Fireflies GraphQL response',
})
} catch (error) {
if (!response.ok && isPayloadSizeLimitError(error)) {
throw new FirefliesApiError(
`Fireflies API HTTP error: ${response.status} (response body exceeded the diagnostic limit)`,
undefined,
response.status,
resolveRetryDelayMs(response.headers)
)
}
throw error
}
let parsedBody: unknown = null
try {
parsedBody = JSON.parse(rawBody)
} catch {
parsedBody = null
}
const data = parsedBody as {
data?: Record<string, unknown> | null
errors?: FirefliesGraphQLError[]
} | null
const headerRetryAfterMs = resolveRetryDelayMs(response.headers)
const firstError = data?.errors?.[0]
if (firstError) {
const safeCode =
typeof firstError.code === 'string' && /^[A-Za-z0-9_.-]{1,64}$/.test(firstError.code)
? firstError.code
: undefined
const code = safeCode ? ` (${safeCode})` : ''
const retryAt = firstError.extensions?.metadata?.retryAfter
const graphQLRetryAfterMs =
typeof retryAt === 'number' && retryAt > Date.now() ? retryAt - Date.now() : undefined
throw new FirefliesApiError(
`Fireflies API error${code}`,
safeCode,
firstError.extensions?.status ?? response.status,
graphQLRetryAfterMs ?? headerRetryAfterMs
)
}
if (!response.ok) {
throw new FirefliesApiError(
`Fireflies API HTTP error: ${response.status} (response body omitted)`,
undefined,
response.status,
headerRetryAfterMs
)
}
/**
* A 2xx carrying neither `errors` nor a `data` object is unreadable — an
* unparseable body, a truncated response, or a proxy interstitial. It must
* raise rather than degrade to an empty result: `listDocuments` would
* otherwise report a confident empty listing and reconcile every stored
* document as deleted. Retrying at the page boundary preserves prior pages.
*/
if (!data || typeof data.data !== 'object' || data.data === null) {
throw new FirefliesMalformedResponseError(response.status)
}
return data.data
},
retryOptions
{
maxRetries: retryOptions?.maxRetries ?? FIREFLIES_DEFAULT_MAX_RETRIES,
initialDelayMs: retryOptions?.initialDelayMs ?? 1000,
maxDelayMs: retryOptions?.maxDelayMs ?? FIREFLIES_DEFAULT_MAX_RETRY_DELAY_MS,
maxRetryAfterMs:
retryOptions?.maxRetryAfterMs ??
retryOptions?.maxDelayMs ??
FIREFLIES_DEFAULT_MAX_RETRY_DELAY_MS,
backoffMultiplier: retryOptions?.backoffMultiplier,
retryCondition: isRetryableFirefliesError,
}
)
/**
* Fireflies reports failures as an `errors` array in the body, and does so on
* non-2xx responses too (`object_not_found` → 404, `too_many_requests` → 429,
* `paid_required` → 403). Read the body first so the caller sees the actual
* reason instead of a bare status code.
*/
const data = (await response.json().catch(() => null)) as {
data?: Record<string, unknown> | null
errors?: { message?: string; code?: string }[]
} | null
const firstError = data?.errors?.[0]
if (firstError) {
const code = firstError.code ? ` (${firstError.code})` : ''
throw new FirefliesApiError(
`Fireflies API error${code}: ${firstError.message || 'Unknown GraphQL error'}`,
firstError.code
)
}
if (!response.ok) {
throw new Error(`Fireflies API HTTP error: ${response.status}`)
}
/**
* A 2xx carrying neither `errors` nor a `data` object is unreadable — an
* unparseable body, a truncated response, a proxy interstitial. It must raise
* rather than degrade to an empty result: `listDocuments` would otherwise
* report a confident empty listing and the sync engine would reconcile every
* stored document as deleted.
*/
if (!data || typeof data.data !== 'object' || data.data === null) {
throw new Error('Fireflies API returned a malformed response with no data')
}
return data.data
}
/**
* Formats transcript sentences into plain text content.
*/
function formatTranscriptContent(transcript: FirefliesTranscript): string {
const parts: string[] = []
let totalBytes = 0
const push = (part: string) => {
const addedBytes = Buffer.byteLength(part, 'utf8') + (parts.length > 0 ? 1 : 0)
if (totalBytes + addedBytes > FIREFLIES_MAX_EXTRACTED_CONTENT_BYTES) {
throw new ConnectorFileTooLargeError(FIREFLIES_MAX_EXTRACTED_CONTENT_BYTES)
}
parts.push(part)
totalBytes += addedBytes
}
if (transcript.title) {
parts.push(`Meeting: ${transcript.title}`)
push(`Meeting: ${transcript.title}`)
}
if (transcript.date) {
parts.push(`Date: ${new Date(transcript.date).toISOString()}`)
push(`Date: ${new Date(transcript.date).toISOString()}`)
}
if (transcript.duration) {
parts.push(`Duration: ${Math.round(transcript.duration)} minutes`)
push(`Duration: ${Math.round(transcript.duration)} minutes`)
}
const host = transcript.host_email || transcript.organizer_email
if (host) {
parts.push(`Host: ${host}`)
push(`Host: ${host}`)
}
if (transcript.participants && transcript.participants.length > 0) {
parts.push(`Participants: ${transcript.participants.join(', ')}`)
push(`Participants: ${transcript.participants.join(', ')}`)
}
const overview = transcript.summary?.overview || transcript.summary?.short_summary
if (overview) {
parts.push('')
parts.push('--- Overview ---')
parts.push(overview)
push('')
push('--- Overview ---')
push(overview)
}
if (transcript.summary?.action_items) {
parts.push('')
parts.push('--- Action Items ---')
parts.push(transcript.summary.action_items)
push('')
push('--- Action Items ---')
push(transcript.summary.action_items)
}
if (transcript.summary?.keywords && transcript.summary.keywords.length > 0) {
parts.push('')
parts.push(`Keywords: ${transcript.summary.keywords.join(', ')}`)
const keywords = transcript.summary?.keywords
const formattedKeywords = Array.isArray(keywords)
? keywords.filter((keyword): keyword is string => typeof keyword === 'string').join(', ')
: typeof keywords === 'string'
? keywords
: ''
if (formattedKeywords) {
push('')
push(`Keywords: ${formattedKeywords}`)
}
if (transcript.sentences && transcript.sentences.length > 0) {
parts.push('')
parts.push('--- Transcript ---')
push('')
push('--- Transcript ---')
for (const sentence of transcript.sentences) {
parts.push(`${sentence.speaker_name}: ${sentence.text}`)
push(`${sentence.speaker_name}: ${sentence.text}`)
}
}
@@ -166,9 +324,21 @@ function formatTranscriptContent(transcript: FirefliesTranscript): string {
* `getDocument`, so the metadata-derived `contentHash` is byte-identical on both
* paths and a hydrated transcript is never seen as changed.
*/
function transcriptToStub(transcript: FirefliesTranscript): ExternalDocument {
async function transcriptToStub(transcript: FirefliesTranscript): Promise<ExternalDocument> {
const meetingDate = transcript.date ? new Date(transcript.date).toISOString() : undefined
const speakerNames = transcript.speakers?.map((s) => s.name).filter(Boolean) ?? []
const lifecycleHash = await computeContentHash(
JSON.stringify({
date: transcript.date ?? null,
duration: transcript.duration ?? null,
title: transcript.title ?? null,
host: transcript.host_email || transcript.organizer_email || null,
participants: transcript.participants ?? [],
speakers: speakerNames,
isLive: transcript.is_live ?? null,
summaryStatus: transcript.meeting_info?.summary_status ?? null,
})
)
return {
externalId: transcript.id,
@@ -177,7 +347,7 @@ function transcriptToStub(transcript: FirefliesTranscript): ExternalDocument {
contentDeferred: true,
mimeType: 'text/plain',
sourceUrl: transcript.transcript_url || undefined,
contentHash: `fireflies:${transcript.id}:${transcript.date ?? ''}:${transcript.duration ?? ''}`,
contentHash: `fireflies:v2:${transcript.id}:${lifecycleHash}`,
metadata: {
hostEmail: transcript.host_email || transcript.organizer_email,
duration: transcript.duration,
@@ -188,6 +358,20 @@ function transcriptToStub(transcript: FirefliesTranscript): ExternalDocument {
}
}
function oversizedTranscriptResponseStub(externalId: string): ExternalDocument {
return markSkipped(
{
externalId,
title: `Fireflies transcript ${externalId}`,
content: '',
contentDeferred: true,
mimeType: 'text/plain',
contentHash: `fireflies:oversized-response:${externalId}`,
},
FIREFLIES_RESPONSE_SKIP_REASON
)
}
export const firefliesConnector: ConnectorConfig = {
...firefliesConnectorMeta,
@@ -198,9 +382,9 @@ export const firefliesConnector: ConnectorConfig = {
syncContext?: Record<string, unknown>
): Promise<ExternalDocumentList> => {
const hostEmail = (sourceConfig.hostEmail as string) || ''
const maxTranscripts = sourceConfig.maxTranscripts ? Number(sourceConfig.maxTranscripts) : 0
const maxTranscripts = parseMaxTranscripts(sourceConfig.maxTranscripts)
const skip = cursor ? Number(cursor) : 0
const skip = parsePaginationCursor(cursor)
const prevFetched = (syncContext?.totalDocsFetched as number) ?? 0
/**
@@ -214,7 +398,9 @@ export const firefliesConnector: ConnectorConfig = {
let listingCeiling = syncContext?.firefliesListingCeiling as string | undefined
if (!listingCeiling) {
listingCeiling = new Date().toISOString()
if (syncContext) syncContext.firefliesListingCeiling = listingCeiling
if (syncContext) {
syncContext.firefliesListingCeiling = listingCeiling
}
}
/**
@@ -268,27 +454,36 @@ export const firefliesConnector: ConnectorConfig = {
speakers {
name
}
is_live
meeting_info {
summary_status
}
}
}`,
variables
)
const transcripts = (
Array.isArray(data.transcripts) ? data.transcripts : []
) as FirefliesTranscript[]
if (!Array.isArray(data.transcripts)) {
throw new Error('Fireflies API returned malformed transcript-list data')
}
if (!data.transcripts.every(isFirefliesTranscript)) {
throw new Error('Fireflies API returned malformed transcript metadata')
}
const transcripts = data.transcripts as FirefliesTranscript[]
const allStubs = transcripts.filter((t) => Boolean(t?.id)).map(transcriptToStub)
const allStubs = await Promise.all(
transcripts.filter((t) => Boolean(t?.id)).map(transcriptToStub)
)
const documents = maxTranscripts > 0 ? allStubs.slice(0, remaining) : allStubs
const totalFetched = prevFetched + documents.length
if (syncContext) syncContext.totalDocsFetched = totalFetched
/**
* `listingCapped` blocks the sync engine's deletion reconciliation, so it is
* set only when the cap actually hid transcripts that still exist — either
* the probe row came back, or the page came back full. A cap that lands
* exactly on source exhaustion leaves a short page and stays reconcilable,
* otherwise deleted meetings could never be removed from the KB.
* Record a configured cap only when it actually hid transcripts — either the
* probe row came back, or the page came back full. The separate
* `reconciliationSafe: false` result reflects the provider's offset pagination
* regardless of whether this user-configured cap was reached.
*/
const moreAvailable = allStubs.length > documents.length || transcripts.length === pageSize
const hitLimit = maxTranscripts > 0 && totalFetched >= maxTranscripts
@@ -306,6 +501,12 @@ export const firefliesConnector: ConnectorConfig = {
*/
nextCursor: hasMore ? String(skip + transcripts.length) : undefined,
hasMore,
/**
* Fireflies exposes offset pagination without a documented stable sort or
* snapshot cursor. `toDate` excludes new transcripts, but a deletion during
* the walk can still shift later offsets and make a live item appear absent.
*/
reconciliationSafe: false,
}
},
@@ -330,6 +531,10 @@ export const firefliesConnector: ConnectorConfig = {
speakers {
name
}
is_live
meeting_info {
summary_status
}
sentences {
speaker_name
text
@@ -345,14 +550,25 @@ export const firefliesConnector: ConnectorConfig = {
{ id: externalId }
)
const transcript = data.transcript as FirefliesTranscript | null
if (!transcript?.id) return null
const transcript = data.transcript
if (!isFirefliesTranscript(transcript) || transcript.id !== externalId) {
throw new Error('Fireflies API returned malformed transcript metadata')
}
const stub = transcriptToStub(transcript)
const stub = await transcriptToStub(transcript)
let content: string
try {
content = formatTranscriptContent(transcript)
} catch (error) {
if (error instanceof ConnectorFileTooLargeError) {
return markSkipped(stub, FIREFLIES_EXTRACTED_CONTENT_SKIP_REASON)
}
throw error
}
return {
...stub,
content: formatTranscriptContent(transcript),
content,
contentDeferred: false,
metadata: {
...stub.metadata,
@@ -360,6 +576,13 @@ export const firefliesConnector: ConnectorConfig = {
},
}
} catch (error) {
if (isPayloadSizeLimitError(error)) {
logger.info('Skipping Fireflies transcript with oversized hydration response', {
externalId,
maxResponseBytes: FIREFLIES_MAX_RESPONSE_BYTES,
})
return oversizedTranscriptResponseStub(externalId)
}
/**
* Only `object_not_found` means the transcript is genuinely gone. Every other
* failure — `too_many_requests`, `paid_required`, transport faults — is rethrown so
@@ -382,9 +605,10 @@ export const firefliesConnector: ConnectorConfig = {
accessToken: string,
sourceConfig: Record<string, unknown>
): Promise<{ valid: boolean; error?: string }> => {
const maxTranscripts = sourceConfig.maxTranscripts as string | undefined
if (maxTranscripts && (Number.isNaN(Number(maxTranscripts)) || Number(maxTranscripts) < 0)) {
return { valid: false, error: 'Max transcripts must be a non-negative number' }
try {
parseMaxTranscripts(sourceConfig.maxTranscripts)
} catch (error) {
return { valid: false, error: toError(error).message }
}
try {
+6
View File
@@ -14,6 +14,12 @@ export const firefliesConnectorMeta: ConnectorMeta = {
placeholder: 'Enter your Fireflies API key',
},
/**
* Fireflies exposes no transcript modification timestamp, so an explicit
* full resync must rehydrate content even when list metadata is unchanged.
*/
rehydrateOnFullSync: true,
configFields: [
{
id: 'hostEmail',
@@ -0,0 +1,707 @@
/**
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/components/icons', () => ({
GoogleDocsIcon: () => null,
}))
import { googleDocsConnector } from '@/connectors/google-docs/google-docs'
const ACCESS_TOKEN = 'token-123'
const DOCUMENT_ID = 'document-abc'
const DRIVE_FILE = {
id: DOCUMENT_ID,
name: 'Product plan',
mimeType: 'application/vnd.google-apps.document',
modifiedTime: '2026-08-24T12:00:00.000Z',
createdTime: '2026-08-01T12:00:00.000Z',
webViewLink: `https://docs.google.com/document/d/${DOCUMENT_ID}/edit`,
owners: [{ displayName: 'Ada Lovelace' }],
}
function stubFetchDocument(docsResponse: Response) {
const fetchMock = vi.fn(async (input: string | URL | Request) => {
const url = new URL(typeof input === 'string' ? input : input.toString())
if (url.hostname === 'www.googleapis.com') {
return new Response(JSON.stringify({ ...DRIVE_FILE, trashed: false }), { status: 200 })
}
if (url.hostname === 'docs.googleapis.com') return docsResponse
throw new Error(`Unexpected fetch to ${url.toString()}`)
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
describe('googleDocsConnector', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('getDocument', () => {
it.each([{}, { ...DRIVE_FILE, id: 'different-document' }])(
'rejects malformed Drive metadata instead of replacing retained content',
async (metadata) => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(JSON.stringify(metadata), { status: 200 }))
)
await expect(
googleDocsConnector.getDocument(ACCESS_TOKEN, {}, DOCUMENT_ID)
).rejects.toThrow('Google Drive API returned malformed file metadata')
}
)
it('authoritatively skips a listed document that changed type', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response(
JSON.stringify({ ...DRIVE_FILE, mimeType: 'application/pdf', trashed: false }),
{ status: 200 }
)
)
)
await expect(
googleDocsConnector.getDocument(ACCESS_TOKEN, {}, DOCUMENT_ID)
).resolves.toMatchObject({
content: '',
skippedReason: 'File is no longer a Google Doc',
skippedExistingDisposition: 'replace',
})
})
it('requests the tab response view and extracts nested tab content', async () => {
const fetchMock = stubFetchDocument(
new Response(
JSON.stringify({
tabs: [
{
documentTab: {
body: {
content: [
{
paragraph: {
paragraphStyle: { namedStyleType: 'HEADING_1' },
elements: [{ textRun: { content: 'Overview\n' } }],
},
},
{
table: {
tableRows: [
{
tableCells: [
{
content: [
{
paragraph: {
elements: [{ textRun: { content: 'Table cell\n' } }],
},
},
],
},
],
},
],
},
},
],
},
},
childTabs: [
{
documentTab: {
body: {
content: [
{
paragraph: {
elements: [
{
richLink: {
richLinkProperties: {
title: 'Linked specification',
uri: 'https://example.com/spec',
},
},
},
],
},
},
],
},
},
},
],
},
],
}),
{ status: 200 }
)
)
const document = await googleDocsConnector.getDocument(ACCESS_TOKEN, {}, DOCUMENT_ID)
expect(document?.content).toBe('# Overview\nTable cell\nLinked specification')
expect(document?.contentDeferred).toBe(false)
const docsCall = fetchMock.mock.calls.find(([input]) =>
input.toString().startsWith('https://docs.googleapis.com/')
)
expect(docsCall).toBeDefined()
const docsUrl = new URL(docsCall?.[0].toString() ?? '')
expect(docsUrl.pathname).toBe(`/v1/documents/${DOCUMENT_ID}`)
expect(docsUrl.searchParams.get('includeTabsContent')).toBe('true')
expect(docsUrl.searchParams.get('fields')).toBe('tabs')
})
it('omits the structured Google API error message when hydration fails', async () => {
stubFetchDocument(
new Response(
JSON.stringify({
error: {
code: 400,
message: 'Invalid field selection tabs',
status: 'INVALID_ARGUMENT',
},
}),
{ status: 400 }
)
)
await expect(googleDocsConnector.getDocument(ACCESS_TOKEN, {}, DOCUMENT_ID)).rejects.toThrow(
`Failed to fetch Google Doc content ${DOCUMENT_ID}: 400`
)
})
it('omits a structured Google API error message from an envelope larger than 2KB', async () => {
stubFetchDocument(
new Response(
JSON.stringify({
padding: 'x'.repeat(3000),
error: {
code: 400,
message: 'Invalid field selection tabs',
status: 'INVALID_ARGUMENT',
},
}),
{ status: 400 }
)
)
await expect(googleDocsConnector.getDocument(ACCESS_TOKEN, {}, DOCUMENT_ID)).rejects.toThrow(
`Failed to fetch Google Doc content ${DOCUMENT_ID}: 400`
)
})
it('omits credentials from bounded Google API diagnostics', async () => {
stubFetchDocument(
new Response(
JSON.stringify({
error: {
code: 403,
message: 'Authorization: Bearer production-secret',
status: 'PERMISSION_DENIED',
},
}),
{ status: 403 }
)
)
await expect(googleDocsConnector.getDocument(ACCESS_TOKEN, {}, DOCUMENT_ID)).rejects.toThrow(
`Failed to fetch Google Doc content ${DOCUMENT_ID}: 403`
)
})
it('redacts canonical credential keys from an unexpected Google error envelope', async () => {
const secret = 'opaque-client-secret-that-must-not-escape'
stubFetchDocument(
new Response(JSON.stringify({ metadata: { client_secret: secret } }), { status: 400 })
)
const error = await googleDocsConnector.getDocument(ACCESS_TOKEN, {}, DOCUMENT_ID).then(
() => undefined,
(caught) => caught as Error
)
expect(error?.message).toBe(`Failed to fetch Google Doc content ${DOCUMENT_ID}: 400`)
expect(error?.message).not.toContain(secret)
})
it('returns only the status when an error envelope exceeds the diagnostic limit', async () => {
const secret = 'over-limit-provider-secret-that-must-not-escape'
stubFetchDocument(
new Response(`${'x'.repeat(64 * 1024)}${secret}`, {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
)
const error = await googleDocsConnector.getDocument(ACCESS_TOKEN, {}, DOCUMENT_ID).then(
() => undefined,
(caught) => caught as Error
)
expect(error?.message).toBe(`Failed to fetch Google Doc content ${DOCUMENT_ID}: 400`)
expect(error?.message).not.toContain('response body omitted')
expect(error?.message).not.toContain(secret)
})
it('marks an empty tab response as an authoritative skip', async () => {
stubFetchDocument(new Response(JSON.stringify({ tabs: [] }), { status: 200 }))
await expect(
googleDocsConnector.getDocument(ACCESS_TOKEN, {}, DOCUMENT_ID)
).resolves.toMatchObject({
content: '',
contentDeferred: false,
skippedExistingDisposition: 'replace',
skippedReason: 'Document contains no extractable text',
})
})
it('rejects a successful response that omits the required tabs array', async () => {
stubFetchDocument(new Response('{}', { status: 200 }))
await expect(googleDocsConnector.getDocument(ACCESS_TOKEN, {}, DOCUMENT_ID)).rejects.toThrow(
'Google Docs API returned a malformed document response'
)
})
it('rejects an empty tab object instead of replacing retained content', async () => {
stubFetchDocument(new Response(JSON.stringify({ tabs: [{}] }), { status: 200 }))
await expect(googleDocsConnector.getDocument(ACCESS_TOKEN, {}, DOCUMENT_ID)).rejects.toThrow(
'Google Docs API returned a malformed document response'
)
})
it('rejects malformed nested tab content instead of dropping it', async () => {
stubFetchDocument(
new Response(
JSON.stringify({
tabs: [
{
documentTab: { body: { content: [] } },
childTabs: [{}],
},
],
}),
{ status: 200 }
)
)
await expect(googleDocsConnector.getDocument(ACCESS_TOKEN, {}, DOCUMENT_ID)).rejects.toThrow(
'Google Docs API returned a malformed document response'
)
})
it('maps an oversized chunked hydration response to a visible skip', async () => {
const chunk = new Uint8Array(34 * 1024 * 1024)
let chunksSent = 0
let streamCancelled = false
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
chunksSent += 1
controller.enqueue(chunk)
},
cancel() {
streamCancelled = true
},
})
stubFetchDocument(new Response(stream, { status: 200 }))
await expect(
googleDocsConnector.getDocument(ACCESS_TOKEN, {}, DOCUMENT_ID)
).resolves.toMatchObject({
externalId: DOCUMENT_ID,
content: '',
contentDeferred: false,
skippedReason: 'File exceeds the 100MB size limit and was not indexed',
})
expect(chunksSent).toBeGreaterThanOrEqual(3)
expect(streamCancelled).toBe(true)
})
it('extracts headers, footers, and footnotes from every tab', async () => {
stubFetchDocument(
new Response(
JSON.stringify({
tabs: [
{
documentTab: {
body: { content: [] },
headers: {
header1: {
content: [
{ paragraph: { elements: [{ textRun: { content: 'Header text\n' } }] } },
],
},
dateHeader: {
content: [
{
paragraph: {
elements: [
{
dateElement: {
dateElementProperties: { displayText: 'Aug 24, 2026' },
},
},
],
},
},
],
},
},
footers: {
footer1: {
content: [
{ paragraph: { elements: [{ textRun: { content: 'Footer text\n' } }] } },
],
},
},
footnotes: {
footnote1: {
content: [
{
paragraph: { elements: [{ textRun: { content: 'Footnote text\n' } }] },
},
],
},
},
},
},
{
documentTab: {
body: { content: [] },
headers: {
siblingHeader: {
content: [
{
paragraph: {
elements: [{ textRun: { content: 'Sibling header\n' } }],
},
},
],
},
},
footnotes: {
siblingFootnote: {
content: [
{
paragraph: {
elements: [{ textRun: { content: 'Sibling footnote\n' } }],
},
},
],
},
},
},
},
],
}),
{ status: 200 }
)
)
await expect(
googleDocsConnector.getDocument(ACCESS_TOKEN, {}, DOCUMENT_ID)
).resolves.toMatchObject({
content:
'Header text\nAug 24, 2026\nFooter text\nFootnote text\nSibling header\nSibling footnote',
contentDeferred: false,
})
})
it('does not authoritatively replace prior content for an embedded-object-only doc', async () => {
stubFetchDocument(
new Response(
JSON.stringify({
tabs: [
{
documentTab: {
body: { content: [] },
inlineObjects: { image1: { inlineObjectProperties: {} } },
},
},
],
}),
{ status: 200 }
)
)
const document = await googleDocsConnector.getDocument(ACCESS_TOKEN, {}, DOCUMENT_ID)
expect(document).toMatchObject({
content: '',
contentDeferred: false,
skippedReason: 'Document contains non-text elements but no extractable text',
})
expect(document?.skippedExistingDisposition).toBeUndefined()
})
it('does not authoritatively replace prior content for an equation-only doc', async () => {
stubFetchDocument(
new Response(
JSON.stringify({
tabs: [
{
documentTab: {
body: {
content: [{ paragraph: { elements: [{ equation: {} }] } }],
},
},
},
],
}),
{ status: 200 }
)
)
const document = await googleDocsConnector.getDocument(ACCESS_TOKEN, {}, DOCUMENT_ID)
expect(document).toMatchObject({
content: '',
skippedReason: 'Document contains non-text elements but no extractable text',
})
expect(document?.skippedExistingDisposition).toBeUndefined()
})
it('keeps the metadata hash identical between listing and hydration', async () => {
let driveRequestCount = 0
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL | Request) => {
const url = new URL(typeof input === 'string' ? input : input.toString())
if (url.hostname === 'www.googleapis.com' && url.pathname === '/drive/v3/files') {
return new Response(JSON.stringify({ files: [DRIVE_FILE] }), { status: 200 })
}
if (url.hostname === 'www.googleapis.com') {
driveRequestCount += 1
return new Response(JSON.stringify({ ...DRIVE_FILE, trashed: false }), { status: 200 })
}
if (url.hostname === 'docs.googleapis.com') {
return new Response(
JSON.stringify({
tabs: [
{
documentTab: {
body: {
content: [
{ paragraph: { elements: [{ textRun: { content: 'Content\n' } }] } },
],
},
},
},
],
}),
{ status: 200 }
)
}
throw new Error(`Unexpected fetch to ${url.toString()}`)
})
)
const listing = await googleDocsConnector.listDocuments(ACCESS_TOKEN, {})
const hydrated = await googleDocsConnector.getDocument(ACCESS_TOKEN, {}, DOCUMENT_ID)
expect(listing.documents[0]?.contentDeferred).toBe(true)
expect(hydrated?.contentHash).toBe(listing.documents[0]?.contentHash)
expect(driveRequestCount).toBe(1)
})
})
describe('listDocuments', () => {
it('rejects a malformed successful Drive list envelope', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 200 })))
await expect(
googleDocsConnector.listDocuments(ACCESS_TOKEN, {}, undefined, {})
).rejects.toThrow('Google Drive API returned malformed file-list metadata')
})
it('accepts a discriminator-only empty Drive list envelope', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ kind: 'drive#fileList' }), { status: 200 })
)
)
await expect(
googleDocsConnector.listDocuments(ACCESS_TOKEN, {}, undefined, {})
).resolves.toMatchObject({ documents: [], hasMore: false })
})
it.each([
{
files: [{ name: 'Missing ID', mimeType: DRIVE_FILE.mimeType, modifiedTime: '2026-01-01' }],
},
{ files: [], nextPageToken: 123 },
{ files: [], incompleteSearch: 'true' },
])('rejects malformed Drive list metadata', async (body) => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(new Response(JSON.stringify(body), { status: 200 }))
)
await expect(
googleDocsConnector.listDocuments(ACCESS_TOKEN, {}, undefined, {})
).rejects.toThrow('Google Drive API returned malformed file-list metadata')
})
it('does not issue another Drive request after a lowered cap is already exhausted', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const syncContext: Record<string, unknown> = { totalDocsFetched: 5 }
const result = await googleDocsConnector.listDocuments(
ACCESS_TOKEN,
{ maxDocs: '2' },
'stale-page-token',
syncContext
)
expect(result).toEqual({ documents: [], hasMore: false })
expect(syncContext.listingCapped).toBe(true)
expect(fetchMock).not.toHaveBeenCalled()
})
it('suppresses reconciliation when Drive reports an incomplete search', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
files: [DRIVE_FILE],
incompleteSearch: true,
}),
{ status: 200 }
)
)
vi.stubGlobal('fetch', fetchMock)
const syncContext: Record<string, unknown> = {}
const result = await googleDocsConnector.listDocuments(
ACCESS_TOKEN,
{},
undefined,
syncContext
)
expect(result.documents.map((document) => document.externalId)).toEqual([DOCUMENT_ID])
expect(result.hasMore).toBe(false)
expect(result.reconciliationSafe).toBe(false)
expect(syncContext.listingCapped).toBe(true)
})
it('keeps an exactly exhausted cap authoritative when Drive is exhausted', async () => {
const files = [DRIVE_FILE, { ...DRIVE_FILE, id: 'document-def', name: 'Launch plan' }]
const fetchMock = vi
.fn()
.mockResolvedValue(new Response(JSON.stringify({ files }), { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
const syncContext: Record<string, unknown> = {}
const result = await googleDocsConnector.listDocuments(
ACCESS_TOKEN,
{ maxDocs: '2' },
undefined,
syncContext
)
expect(result.documents.map((document) => document.externalId)).toEqual([
DOCUMENT_ID,
'document-def',
])
expect(result).toMatchObject({ hasMore: false })
expect(result.nextCursor).toBeUndefined()
expect(syncContext.listingCapped).toBeUndefined()
expect(new URL(String(fetchMock.mock.calls[0][0])).searchParams.get('pageSize')).toBe('2')
})
it('suppresses reconciliation when the cap is reached with another Drive page', async () => {
const files = [DRIVE_FILE, { ...DRIVE_FILE, id: 'document-def', name: 'Launch plan' }]
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
files,
nextPageToken: 'next-page-token',
}),
{ status: 200 }
)
)
vi.stubGlobal('fetch', fetchMock)
const syncContext: Record<string, unknown> = {}
const result = await googleDocsConnector.listDocuments(
ACCESS_TOKEN,
{ maxDocs: '2' },
undefined,
syncContext
)
expect(result.documents).toHaveLength(2)
expect(result.hasMore).toBe(false)
expect(result.nextCursor).toBeUndefined()
expect(syncContext.listingCapped).toBe(true)
})
it.each(['1.5', 'Infinity', 1.5, Number.POSITIVE_INFINITY])(
'rejects invalid persisted maxDocs %s before calling Google Drive',
async (maxDocs) => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await expect(googleDocsConnector.listDocuments(ACCESS_TOKEN, { maxDocs })).rejects.toThrow(
'Max documents must be a positive safe integer, or 0 for unlimited'
)
expect(fetchMock).not.toHaveBeenCalled()
}
)
it.each([undefined, null, '', ' ', 0, '0'])(
'keeps omitted or explicit unlimited maxDocs %s valid at runtime',
async (maxDocs) => {
const fetchMock = vi
.fn()
.mockResolvedValue(new Response(JSON.stringify({ files: [] }), { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
await expect(
googleDocsConnector.listDocuments(ACCESS_TOKEN, { maxDocs })
).resolves.toMatchObject({ documents: [], hasMore: false })
expect(new URL(String(fetchMock.mock.calls[0][0])).searchParams.get('pageSize')).toBe('100')
}
)
})
describe('validateConfig', () => {
it.each(['1.5', 'Infinity', 1.5, Number.POSITIVE_INFINITY])(
'rejects invalid maxDocs %s before calling Google Drive',
async (maxDocs) => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const result = await googleDocsConnector.validateConfig(ACCESS_TOKEN, { maxDocs })
expect(result).toEqual({
valid: false,
error: 'Max documents must be a positive safe integer, or 0 for unlimited',
})
expect(fetchMock).not.toHaveBeenCalled()
}
)
})
})
+271 -49
View File
@@ -1,6 +1,11 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
import { isPlainRecord } from '@sim/utils/object'
import {
fetchWithRetry,
readBoundedHttpErrorBody,
VALIDATE_RETRY_OPTIONS,
} from '@/lib/knowledge/documents/utils'
import { googleDocsConnectorMeta } from '@/connectors/google-docs/meta'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import {
@@ -9,6 +14,7 @@ import {
joinTagArray,
markSkipped,
parseMultiValue,
parseOptionalUnlimitedSafeInteger,
parseTagDate,
readBodyWithLimit,
sizeLimitSkipReason,
@@ -30,6 +36,19 @@ const MAX_DOCS_RESPONSE_BYTES = 100 * 1024 * 1024
const PAGE_SIZE = 100
const GOOGLE_DOC_MIME_TYPE = 'application/vnd.google-apps.document'
const MAX_DOCS_VALIDATION_ERROR =
'Max documents must be a positive safe integer, or 0 for unlimited'
function parseMaxDocs(value: unknown): number {
return parseOptionalUnlimitedSafeInteger(value, MAX_DOCS_VALIDATION_ERROR)
}
/**
* `includeTabsContent=true` switches the Docs response to the tab representation.
* Request only that representation because the legacy top-level `body` is empty
* in tab mode. Google accepts `fields=tabs`.
*/
const GOOGLE_DOC_CONTENT_FIELDS = 'tabs'
/**
* Represents a Google Drive file entry returned by the Drive API.
@@ -44,6 +63,40 @@ interface DriveFile {
owners?: { displayName?: string; emailAddress?: string }[]
}
function isDriveFileMetadata(
value: unknown,
expectedId: string
): value is DriveFile & { trashed?: boolean } {
return (
isPlainRecord(value) &&
value.id === expectedId &&
typeof value.name === 'string' &&
typeof value.mimeType === 'string' &&
(value.trashed === undefined || typeof value.trashed === 'boolean')
)
}
function isDriveFileListItem(value: unknown): value is DriveFile {
return (
isPlainRecord(value) &&
typeof value.id === 'string' &&
value.id.length > 0 &&
typeof value.name === 'string' &&
typeof value.mimeType === 'string' &&
typeof value.modifiedTime === 'string'
)
}
function parseDriveFileMetadata(
value: unknown,
expectedId: string
): DriveFile & { trashed?: boolean } {
if (!isDriveFileMetadata(value, expectedId)) {
throw new Error('Google Drive API returned malformed file metadata')
}
return value
}
/**
* A single element inside a paragraph. Only the variants that carry readable
* text are modeled — `pageBreak`, `columnBreak`, `horizontalRule`, `equation`,
@@ -53,6 +106,9 @@ interface DocsParagraphElement {
textRun?: { content?: string }
richLink?: { richLinkProperties?: { title?: string; uri?: string } }
person?: { personProperties?: { name?: string; email?: string } }
dateElement?: { dateElementProperties?: { displayText?: string } }
equation?: Record<string, unknown>
inlineObjectElement?: { inlineObjectId?: string }
}
/**
@@ -81,29 +137,93 @@ interface DocsStructuralElement {
}
}
interface DocsContentRegion {
content?: DocsStructuralElement[]
}
/**
* A tab of a Google Doc. Tabs may nest arbitrarily deep via `childTabs`.
* A Google Doc tab. Tabs may nest through `childTabs`; non-text objects are
* tracked separately so an object-only document is not mistaken for empty text.
*/
interface DocsTab {
documentTab?: {
body?: { content?: DocsStructuralElement[] }
body?: DocsContentRegion
headers?: Record<string, DocsContentRegion>
footers?: Record<string, DocsContentRegion>
footnotes?: Record<string, DocsContentRegion>
inlineObjects?: Record<string, unknown>
positionedObjects?: Record<string, unknown>
}
childTabs?: DocsTab[]
}
/**
* Represents the response from the Google Docs API for a single document. With
* `includeTabsContent=true` the content lands in `tabs` and the legacy `body`
* field is left empty; `body` is retained only as a fallback in case the request
* is ever served without tab content.
* Represents the tab-based response from the Google Docs API for one document.
* With `includeTabsContent=true`, the legacy top-level text fields are empty.
*/
interface DocsDocument {
body?: {
content?: DocsStructuralElement[]
}
tabs?: DocsTab[]
}
function isDocsTab(value: unknown): value is DocsTab {
if (!isPlainRecord(value)) return false
if (value.childTabs !== undefined) {
if (!Array.isArray(value.childTabs) || !value.childTabs.every(isDocsTab)) return false
}
if (value.documentTab !== undefined) {
if (!isPlainRecord(value.documentTab)) return false
const body = value.documentTab.body
if (
body !== undefined &&
(!isPlainRecord(body) || (body.content !== undefined && !Array.isArray(body.content)))
) {
return false
}
return true
}
return Array.isArray(value.childTabs) && value.childTabs.length > 0
}
function parseDriveFileListResponse(value: unknown): {
files: DriveFile[]
incompleteSearch: boolean
nextPageToken?: string
} {
if (!isPlainRecord(value)) {
throw new Error('Google Drive API returned malformed file-list metadata')
}
const rawFiles = value.files
if (rawFiles === undefined && value.kind !== 'drive#fileList') {
throw new Error('Google Drive API returned malformed file-list metadata')
}
if (
rawFiles !== undefined &&
(!Array.isArray(rawFiles) || rawFiles.some((file) => !isDriveFileListItem(file)))
) {
throw new Error('Google Drive API returned malformed file-list metadata')
}
if (
value.nextPageToken !== undefined &&
(typeof value.nextPageToken !== 'string' || value.nextPageToken.length === 0)
) {
throw new Error('Google Drive API returned malformed file-list metadata')
}
if (value.incompleteSearch !== undefined && typeof value.incompleteSearch !== 'boolean') {
throw new Error('Google Drive API returned malformed file-list metadata')
}
return {
files: rawFiles ?? [],
incompleteSearch: value.incompleteSearch === true,
...(typeof value.nextPageToken === 'string' ? { nextPageToken: value.nextPageToken } : {}),
}
}
/** Describes a Google API failure without leaking an unbounded response body. */
async function describeGoogleApiFailure(response: Response): Promise<string> {
await readBoundedHttpErrorBody(response)
return String(response.status)
}
/**
* Maps a Google Docs heading style to a Markdown heading prefix.
*/
@@ -141,6 +261,9 @@ function paragraphElementText(element: DocsParagraphElement): string {
const { name, email } = element.person.personProperties
return name || email || ''
}
if (element.dateElement?.dateElementProperties?.displayText) {
return element.dateElement.dateElementProperties.displayText
}
return ''
}
@@ -189,31 +312,99 @@ function extractTextFromStructuralElements(elements: DocsStructuralElement[]): s
* Collects the text of a tab and every descendant tab, depth-first in the order
* the Docs API returns them.
*/
function extractTextFromTabs(tabs: DocsTab[]): string[] {
const parts: string[] = []
interface DocsExtraction {
parts: string[]
hasUnresolvedContent: boolean
}
for (const tab of tabs) {
const content = tab.documentTab?.body?.content
if (content) parts.push(...extractTextFromStructuralElements(content))
if (tab.childTabs?.length) parts.push(...extractTextFromTabs(tab.childTabs))
function hasUnresolvedStructuralContent(elements: DocsStructuralElement[]): boolean {
for (const element of elements) {
if (
element.paragraph?.elements?.some(
(paragraphElement) =>
Boolean(paragraphElement.equation) || Boolean(paragraphElement.inlineObjectElement)
)
) {
return true
}
for (const row of element.table?.tableRows ?? []) {
for (const cell of row.tableCells ?? []) {
if (cell.content && hasUnresolvedStructuralContent(cell.content)) return true
}
}
if (
element.tableOfContents?.content &&
hasUnresolvedStructuralContent(element.tableOfContents.content)
) {
return true
}
}
return parts
return false
}
function appendRegionText(parts: string[], regions?: Record<string, DocsContentRegion>): boolean {
if (!regions) return false
let hasUnresolvedContent = false
for (const region of Object.values(regions)) {
if (region.content) {
parts.push(...extractTextFromStructuralElements(region.content))
hasUnresolvedContent ||= hasUnresolvedStructuralContent(region.content)
}
}
return hasUnresolvedContent
}
function extractTextFromTabs(tabs: DocsTab[]): DocsExtraction {
const parts: string[] = []
let hasUnresolvedContent = false
for (const tab of tabs) {
const documentTab = tab.documentTab
const content = documentTab?.body?.content
if (content) {
parts.push(...extractTextFromStructuralElements(content))
hasUnresolvedContent ||= hasUnresolvedStructuralContent(content)
}
if (appendRegionText(parts, documentTab?.headers)) hasUnresolvedContent = true
if (appendRegionText(parts, documentTab?.footers)) hasUnresolvedContent = true
if (appendRegionText(parts, documentTab?.footnotes)) hasUnresolvedContent = true
if (
Object.keys(documentTab?.inlineObjects ?? {}).length > 0 ||
Object.keys(documentTab?.positionedObjects ?? {}).length > 0
) {
hasUnresolvedContent = true
}
if (tab.childTabs?.length) {
const child = extractTextFromTabs(tab.childTabs)
parts.push(...child.parts)
hasUnresolvedContent ||= child.hasUnresolvedContent
}
}
return { parts, hasUnresolvedContent }
}
/**
* Extracts plain text from a Google Docs API document response. `tabs` is the
* source of truth because `includeTabsContent=true` moves all content there and
* leaves `body` empty; `body` is read only when `tabs` yields nothing, so a
* response served without tab content still indexes instead of coming back blank.
* leaves the legacy top-level fields empty. The unresolved-content bit prevents
* an image- or equation-only document from being mistaken for an empty one.
*/
function extractTextFromDocument(doc: DocsDocument): string {
const parts = doc.tabs?.length ? extractTextFromTabs(doc.tabs) : []
if (parts.length === 0 && doc.body?.content) {
parts.push(...extractTextFromStructuralElements(doc.body.content))
function extractTextFromDocument(doc: DocsDocument): {
content: string
hasUnresolvedContent: boolean
} {
if (!doc.tabs?.length) return { content: '', hasUnresolvedContent: false }
const extracted = extractTextFromTabs(doc.tabs)
return {
content: extracted.parts.join('\n').trim(),
hasUnresolvedContent: extracted.hasUnresolvedContent,
}
return parts.join('\n').trim()
}
/**
@@ -224,10 +415,13 @@ function extractTextFromDocument(doc: DocsDocument): string {
* {@link MAX_DOCS_RESPONSE_BYTES} so it surfaces as a visible skipped row rather
* than being buffered whole.
*/
async function fetchDocContent(accessToken: string, documentId: string): Promise<string> {
async function fetchDocContent(
accessToken: string,
documentId: string
): Promise<{ content: string; hasUnresolvedContent: boolean }> {
const params = new URLSearchParams({
includeTabsContent: 'true',
fields: 'body.content,tabs',
fields: GOOGLE_DOC_CONTENT_FIELDS,
})
const url = `https://docs.googleapis.com/v1/documents/${encodeURIComponent(documentId)}?${params.toString()}`
@@ -240,14 +434,19 @@ async function fetchDocContent(accessToken: string, documentId: string): Promise
})
if (!response.ok) {
throw new Error(`Failed to fetch Google Doc content ${documentId}: ${response.status}`)
throw new Error(
`Failed to fetch Google Doc content ${documentId}: ${await describeGoogleApiFailure(response)}`
)
}
const buffer = await readBodyWithLimit(response, MAX_DOCS_RESPONSE_BYTES)
if (!buffer) throw new ConnectorFileTooLargeError(MAX_DOCS_RESPONSE_BYTES)
const doc = JSON.parse(buffer.toString('utf8')) as DocsDocument
return extractTextFromDocument(doc)
const parsed: unknown = JSON.parse(buffer.toString('utf8'))
if (!isPlainRecord(parsed) || !Array.isArray(parsed.tabs) || !parsed.tabs.every(isDocsTab)) {
throw new Error('Google Docs API returned a malformed document response')
}
return extractTextFromDocument({ tabs: parsed.tabs })
}
/**
@@ -302,10 +501,14 @@ export const googleDocsConnector: ConnectorConfig = {
): Promise<ExternalDocumentList> => {
const query = buildQuery(sourceConfig)
const maxDocs = sourceConfig.maxDocs ? Number(sourceConfig.maxDocs) : 0
const maxDocs = parseMaxDocs(sourceConfig.maxDocs)
const previouslyFetched = (syncContext?.totalDocsFetched as number) ?? 0
/** Last-page precision: never ask Drive for more files than the cap still allows. */
const remaining = maxDocs > 0 ? Math.max(0, maxDocs - previouslyFetched) : 0
if (maxDocs > 0 && remaining === 0) {
if (syncContext) syncContext.listingCapped = true
return { documents: [], hasMore: false }
}
const pageSize = remaining > 0 ? Math.min(PAGE_SIZE, remaining) : PAGE_SIZE
/**
@@ -323,7 +526,7 @@ export const googleDocsConnector: ConnectorConfig = {
pageSize: String(pageSize),
orderBy: 'modifiedTime desc',
fields:
'nextPageToken,incompleteSearch,files(id,name,mimeType,modifiedTime,createdTime,webViewLink,owners)',
'kind,nextPageToken,incompleteSearch,files(id,name,mimeType,modifiedTime,createdTime,webViewLink,owners)',
supportsAllDrives: 'true',
includeItemsFromAllDrives: 'true',
})
@@ -345,16 +548,16 @@ export const googleDocsConnector: ConnectorConfig = {
})
if (!response.ok) {
const errorText = await response.text()
const failure = await describeGoogleApiFailure(response)
logger.error('Failed to list Google Docs', {
status: response.status,
error: errorText,
error: failure,
})
throw new Error(`Failed to list Google Docs: ${response.status}`)
throw new Error(`Failed to list Google Docs: ${failure}`)
}
const data = await response.json()
const files = (data.files || []) as DriveFile[]
const data = parseDriveFileListResponse(await response.json())
const files = data.files
/**
* Drive sets `incompleteSearch` when it could not search every corpus (it
@@ -394,6 +597,7 @@ export const googleDocsConnector: ConnectorConfig = {
documents,
nextCursor: hitLimit ? undefined : nextPageToken,
hasMore: hitLimit ? false : Boolean(nextPageToken),
reconciliationSafe: incompleteSearch ? false : undefined,
}
},
@@ -415,17 +619,35 @@ export const googleDocsConnector: ConnectorConfig = {
if (!response.ok) {
if (response.status === 404) return null
throw new Error(`Failed to get Google Doc metadata: ${response.status}`)
throw new Error(
`Failed to get Google Doc metadata: ${await describeGoogleApiFailure(response)}`
)
}
const file = (await response.json()) as DriveFile & { trashed?: boolean }
const file = parseDriveFileMetadata(await response.json(), externalId)
if (file.trashed) return null
if (file.mimeType !== GOOGLE_DOC_MIME_TYPE) return null
if (file.mimeType !== GOOGLE_DOC_MIME_TYPE) {
return {
...markSkipped(fileToStub(file), 'File is no longer a Google Doc'),
skippedExistingDisposition: 'replace',
}
}
try {
const content = await fetchDocContent(accessToken, file.id)
if (!content.trim()) return null
const { content, hasUnresolvedContent } = await fetchDocContent(accessToken, file.id)
if (!content.trim()) {
if (hasUnresolvedContent) {
return markSkipped(
fileToStub(file),
'Document contains non-text elements but no extractable text'
)
}
return {
...markSkipped(fileToStub(file), 'Document contains no extractable text'),
skippedExistingDisposition: 'replace',
}
}
return { ...fileToStub(file), content, contentDeferred: false }
} catch (error) {
@@ -452,13 +674,10 @@ export const googleDocsConnector: ConnectorConfig = {
sourceConfig: Record<string, unknown>
): Promise<{ valid: boolean; error?: string }> => {
const folderIds = parseMultiValue(sourceConfig.folderId)
const maxDocs = sourceConfig.maxDocs as string | undefined
if (maxDocs && (Number.isNaN(Number(maxDocs)) || Number(maxDocs) <= 0)) {
return { valid: false, error: 'Max documents must be a positive number' }
}
try {
parseMaxDocs(sourceConfig.maxDocs)
if (folderIds.length > 0) {
for (const folderId of folderIds) {
const url = `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(folderId)}?fields=id,name,mimeType&supportsAllDrives=true`
@@ -483,7 +702,7 @@ export const googleDocsConnector: ConnectorConfig = {
}
return {
valid: false,
error: `Failed to access folder "${folderId}": ${response.status}`,
error: `Failed to access folder "${folderId}": ${await describeGoogleApiFailure(response)}`,
}
}
@@ -514,7 +733,10 @@ export const googleDocsConnector: ConnectorConfig = {
)
if (!response.ok) {
return { valid: false, error: `Failed to access Google Docs: ${response.status}` }
return {
valid: false,
error: `Failed to access Google Docs: ${await describeGoogleApiFailure(response)}`,
}
}
}
@@ -0,0 +1,142 @@
import { readBodyWithLimit } from '@/connectors/utils'
const GOOGLE_ERROR_BODY_MAX_BYTES = 64 * 1024
const GOOGLE_ERROR_REASON_MAX_COUNT = 16
const EXPORT_TOO_LARGE_REASONS = new Set(['exportSizeLimitExceeded'])
const PERMISSION_REASONS = new Set([
'appNotAuthorizedToFile',
'insufficientFilePermissions',
'teamDriveMembershipRequired',
])
const POLICY_REASONS = new Set(['domainPolicy', 'download_restricted_for_revision'])
const UNSUPPORTED_EXPORT_REASONS = new Set(['fileNotDownloadable', 'fileNotExportable'])
const QUOTA_REASONS = new Set(['dailyLimitExceeded', 'quotaExceeded'])
const TRANSIENT_REASONS = new Set([
'backendError',
'internalError',
'rateLimitExceeded',
'sharingRateLimitExceeded',
'userRateLimitExceeded',
])
export type GoogleDriveErrorKind =
| 'authorization'
| 'export_too_large'
| 'not_found'
| 'permission'
| 'policy'
| 'quota'
| 'transient'
| 'unknown'
| 'unsupported_export'
interface GoogleErrorEntry {
reason?: string
}
interface ParsedGoogleErrorBody {
error?: {
errors?: GoogleErrorEntry[]
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function optionalString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined
}
function parseErrorBody(value: unknown): ParsedGoogleErrorBody | undefined {
if (!isRecord(value) || !isRecord(value.error)) return undefined
const entries = Array.isArray(value.error.errors)
? value.error.errors.flatMap((entry): GoogleErrorEntry[] => {
if (!isRecord(entry)) return []
return [
{
reason: optionalString(entry.reason),
},
]
})
: undefined
return {
error: {
errors: entries,
},
}
}
function normalizeReason(reason: string): string | undefined {
const normalized = reason.trim()
return /^[A-Za-z][A-Za-z0-9_.-]{0,99}$/.test(normalized) ? normalized : undefined
}
function classifyGoogleDriveError(
status: number,
reasons: readonly string[]
): GoogleDriveErrorKind {
if (status === 429 || status >= 500) return 'transient'
if (reasons.some((reason) => EXPORT_TOO_LARGE_REASONS.has(reason))) {
return 'export_too_large'
}
if (status === 404 || reasons.includes('notFound')) return 'not_found'
if (status === 401 || reasons.includes('authError')) return 'authorization'
if (reasons.some((reason) => PERMISSION_REASONS.has(reason))) return 'permission'
if (reasons.some((reason) => POLICY_REASONS.has(reason))) return 'policy'
if (reasons.some((reason) => UNSUPPORTED_EXPORT_REASONS.has(reason))) {
return 'unsupported_export'
}
if (reasons.some((reason) => QUOTA_REASONS.has(reason))) return 'quota'
if (reasons.some((reason) => TRANSIENT_REASONS.has(reason))) {
return 'transient'
}
return 'unknown'
}
export class GoogleDriveApiError extends Error {
retryAfterMs?: number
constructor(
readonly status: number,
readonly reasons: readonly string[],
readonly kind: GoogleDriveErrorKind
) {
const reasonSuffix = reasons.length > 0 ? ` (${reasons.join(', ')})` : ''
super(`Google Drive API request failed with HTTP ${status}${reasonSuffix}`)
this.name = 'GoogleDriveApiError'
}
}
/**
* Parses Google's structured error envelope without retaining or logging the raw
* response body. Error payloads are byte-bounded, free-form provider messages
* are omitted, and only validated machine-readable reason tokens survive.
*/
export async function readGoogleDriveApiError(response: Response): Promise<GoogleDriveApiError> {
const body = await readBodyWithLimit(response, GOOGLE_ERROR_BODY_MAX_BYTES).catch(() => null)
let parsedBody: ParsedGoogleErrorBody | undefined
if (body) {
try {
parsedBody = parseErrorBody(JSON.parse(body.toString('utf8')))
} catch {
parsedBody = undefined
}
}
const entries = parsedBody?.error?.errors ?? []
const rawReasons = [...new Set(entries.flatMap((entry) => (entry.reason ? [entry.reason] : [])))]
const reasons = [...new Set(rawReasons.flatMap((reason) => normalizeReason(reason) ?? []))].slice(
0,
GOOGLE_ERROR_REASON_MAX_COUNT
)
return new GoogleDriveApiError(
response.status,
reasons,
classifyGoogleDriveError(response.status, rawReasons)
)
}
@@ -0,0 +1,470 @@
/**
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import * as XLSX from 'xlsx'
const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn() }))
vi.mock('@/components/icons', () => ({ GoogleDriveIcon: () => null }))
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
import { googleDriveConnector } from '@/connectors/google-drive/google-drive'
import {
GoogleDriveApiError,
readGoogleDriveApiError,
} from '@/connectors/google-drive/google-drive-errors'
import { CONNECTOR_MAX_FILE_BYTES } from '@/connectors/utils'
const FILE_ID = 'drive-file-1'
const GOOGLE_DOCUMENT_MIME_TYPE = 'application/vnd.google-apps.document'
const GOOGLE_SPREADSHEET_MIME_TYPE = 'application/vnd.google-apps.spreadsheet'
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
function driveErrorResponse(reason: string, message: string, status = 403): Response {
return jsonResponse(
{
error: {
code: status,
errors: [{ domain: 'global', reason, message }],
message,
},
},
status
)
}
function fileMetadata(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: FILE_ID,
name: 'Product plan',
mimeType: GOOGLE_DOCUMENT_MIME_TYPE,
modifiedTime: '2026-08-20T12:00:00Z',
webViewLink: `https://drive.google.com/file/d/${FILE_ID}/view`,
...overrides,
}
}
async function hydrateWithExportResponse(exportResponse: Response) {
mockFetch
.mockResolvedValueOnce(jsonResponse(fileMetadata()))
.mockResolvedValueOnce(exportResponse)
return googleDriveConnector.getDocument('token', {}, FILE_ID)
}
describe('Google Drive API error parsing', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('fetch', mockFetch)
})
it.each([
['exportSizeLimitExceeded', 'export_too_large'],
['insufficientFilePermissions', 'permission'],
['appNotAuthorizedToFile', 'permission'],
['domainPolicy', 'policy'],
['fileNotExportable', 'unsupported_export'],
['dailyLimitExceeded', 'quota'],
['rateLimitExceeded', 'transient'],
['userRateLimitExceeded', 'transient'],
] as const)('classifies %s as %s', async (reason, kind) => {
const error = await readGoogleDriveApiError(driveErrorResponse(reason, 'Provider message'))
expect(error).toBeInstanceOf(GoogleDriveApiError)
expect(error.kind).toBe(kind)
expect(error.reasons).toEqual([reason])
})
it('classifies retryable statuses even without a structured reason', async () => {
const error = await readGoogleDriveApiError(
new Response('upstream unavailable', { status: 503 })
)
expect(error.kind).toBe('transient')
expect(error.message).not.toContain('upstream unavailable')
})
it('omits provider messages from diagnostics', async () => {
const message = `Authorization: Bearer private-token\ncontext ${'x'.repeat(700)}`
const error = await readGoogleDriveApiError(
driveErrorResponse('insufficientFilePermissions', message)
)
expect(error.message).not.toContain('private-token')
})
it('bounds and redacts provider reasons without losing classification', async () => {
const secret = 'sk-provider-controlled-secret-value'
const reasons = [
...Array.from({ length: 20 }, (_, index) => `${index}-${secret}-${'x'.repeat(200)}`),
'quotaExceeded',
]
const error = await readGoogleDriveApiError(
jsonResponse(
{
error: {
errors: reasons.map((reason) => ({ reason })),
message: 'Provider message',
},
},
403
)
)
expect(error.kind).toBe('quota')
expect(error.reasons).toEqual(['quotaExceeded'])
expect(JSON.stringify(error.reasons)).not.toContain(secret)
expect(error.message).not.toContain(secret)
})
it('discards an error envelope that exceeds the diagnostic body limit', async () => {
const sentinel = 'sk-over-cap-provider-secret-value'
const body = JSON.stringify({
error: {
errors: [{ reason: 'quotaExceeded' }],
message: `${'x'.repeat(64 * 1024)}${sentinel}`,
},
})
const error = await readGoogleDriveApiError(
new Response(body, { status: 403, headers: { 'Content-Type': 'application/json' } })
)
expect(error.kind).toBe('unknown')
expect(error.reasons).toEqual([])
expect(error.message).not.toContain(sentinel)
})
})
describe('Google Drive metadata hydration', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('fetch', mockFetch)
})
it.each([{}, { ...fileMetadata(), id: 'different-file' }])(
'rejects malformed metadata instead of replacing retained content',
async (metadata) => {
mockFetch.mockResolvedValueOnce(jsonResponse(metadata))
await expect(googleDriveConnector.getDocument('token', {}, FILE_ID)).rejects.toThrow(
'Google Drive API returned malformed file metadata'
)
}
)
})
describe('Google Drive export failures', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('fetch', mockFetch)
})
it('records the documented export size limit as a terminal skipped document', async () => {
const document = await hydrateWithExportResponse(
driveErrorResponse('exportSizeLimitExceeded', 'Export exceeds the 10 MB limit')
)
expect(document?.contentDeferred).toBe(false)
expect(document?.skippedReason).toContain('10MB size limit')
expect(mockFetch).toHaveBeenCalledTimes(2)
})
it.each([
['notFound', 'File not found.', 404],
[
'insufficientFilePermissions',
'The user does not have sufficient permissions for this file.',
403,
],
['domainPolicy', 'The domain administrators have disabled Drive apps.', 403],
['fileNotExportable', 'This file cannot be exported.', 403],
])(
'propagates recoverable %s failures instead of persisting a sticky same-hash skip',
async (reason, message, status) => {
await expect(
hydrateWithExportResponse(driveErrorResponse(reason, message, status))
).rejects.toMatchObject({ name: 'GoogleDriveApiError', status })
expect(mockFetch).toHaveBeenCalledTimes(2)
}
)
it('retries a body-classified 403 rate limit and succeeds', async () => {
vi.useFakeTimers()
mockFetch
.mockResolvedValueOnce(jsonResponse(fileMetadata()))
.mockResolvedValueOnce(
driveErrorResponse('userRateLimitExceeded', 'User Rate Limit Exceeded')
)
.mockResolvedValueOnce(new Response('recovered content', { status: 200 }))
const documentPromise = googleDriveConnector.getDocument('token', {}, FILE_ID)
await vi.runAllTimersAsync()
await expect(documentPromise).resolves.toMatchObject({
content: 'recovered content',
})
expect(mockFetch).toHaveBeenCalledTimes(3)
})
it('retains header-classified 403 rate-limit retries when the body has no known reason', async () => {
vi.useFakeTimers()
mockFetch
.mockResolvedValueOnce(jsonResponse(fileMetadata()))
.mockResolvedValueOnce(
new Response(JSON.stringify({ error: { message: 'Temporarily throttled' } }), {
status: 403,
headers: { 'Content-Type': 'application/json', 'Retry-After': '1' },
})
)
.mockResolvedValueOnce(new Response('recovered content', { status: 200 }))
const documentPromise = googleDriveConnector.getDocument('token', {}, FILE_ID)
await vi.runAllTimersAsync()
await expect(documentPromise).resolves.toMatchObject({ content: 'recovered content' })
expect(mockFetch).toHaveBeenCalledTimes(3)
})
it.each([
[429, 'insufficientFilePermissions'],
[503, 'exportSizeLimitExceeded'],
])(
'retries HTTP %i even when the provider reason is classified as terminal',
async (status, reason) => {
vi.useFakeTimers()
mockFetch
.mockResolvedValueOnce(jsonResponse(fileMetadata()))
.mockResolvedValueOnce(driveErrorResponse(reason, 'Conflicting provider reason', status))
.mockResolvedValueOnce(new Response('recovered content', { status: 200 }))
const documentPromise = googleDriveConnector.getDocument('token', {}, FILE_ID)
await vi.runAllTimersAsync()
await expect(documentPromise).resolves.toMatchObject({ content: 'recovered content' })
expect(mockFetch).toHaveBeenCalledTimes(3)
}
)
it('propagates unknown 403 responses instead of misclassifying them as permanent', async () => {
await expect(
hydrateWithExportResponse(driveErrorResponse('newGoogleReason', 'Undocumented failure'))
).rejects.toMatchObject({
name: 'GoogleDriveApiError',
status: 403,
kind: 'unknown',
reasons: ['newGoogleReason'],
})
})
it('hands the complete XLSX workbook to the shared parser instead of exporting only sheet one', async () => {
const workbook = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([]), 'Empty first sheet')
XLSX.utils.book_append_sheet(
workbook,
XLSX.utils.aoa_to_sheet([
['month', 'revenue'],
['Jan', 100],
]),
'Revenue'
)
const workbookBytes = Buffer.from(XLSX.write(workbook, { bookType: 'xlsx', type: 'buffer' }))
mockFetch
.mockResolvedValueOnce(
jsonResponse(
fileMetadata({ name: 'Revenue model', mimeType: GOOGLE_SPREADSHEET_MIME_TYPE })
)
)
.mockResolvedValueOnce(new Response(workbookBytes))
const document = await googleDriveConnector.getDocument('token', {}, FILE_ID)
const exportUrl = String(mockFetch.mock.calls[1][0])
expect(exportUrl).toContain(
'mimeType=application%2Fvnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
expect(document?.content).toBe('')
expect(document?.mimeType).toBe(
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
expect(document?.sourceFile).toMatchObject({
fileName: 'Revenue model.xlsx',
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})
expect(document?.sourceFile?.bytes).toEqual(workbookBytes)
expect(document?.skippedReason).toBeUndefined()
expect(document?.contentHash).toBe('gdrive:v2:drive-file-1:2026-08-20T12:00:00Z')
})
it('marks an empty export as an authoritative skip', async () => {
const document = await hydrateWithExportResponse(new Response(' '))
expect(document).toMatchObject({
content: '',
contentDeferred: false,
skippedExistingDisposition: 'replace',
skippedReason: 'Document contains no extractable text',
})
})
it('authoritatively skips a listed file that changed to an unsupported type', async () => {
mockFetch.mockResolvedValueOnce(
jsonResponse(fileMetadata({ name: 'diagram.png', mimeType: 'image/png' }))
)
await expect(googleDriveConnector.getDocument('token', {}, FILE_ID)).resolves.toMatchObject({
content: '',
skippedReason: 'File is no longer an indexable document',
skippedExistingDisposition: 'replace',
})
expect(mockFetch).toHaveBeenCalledTimes(1)
})
})
describe('Google Drive connector limits', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('fetch', mockFetch)
})
it('does not let an oversized skipped file consume the maxFiles budget', async () => {
mockFetch
.mockResolvedValueOnce(
jsonResponse({
files: [
fileMetadata({
id: 'oversized',
name: 'oversized.txt',
mimeType: 'text/plain',
size: String(CONNECTOR_MAX_FILE_BYTES + 1),
}),
],
nextPageToken: 'next-page',
})
)
.mockResolvedValueOnce(
jsonResponse({
files: [
fileMetadata({
id: 'indexable',
name: 'notes.txt',
mimeType: 'text/plain',
size: '12',
}),
],
})
)
const syncContext: Record<string, unknown> = {}
const first = await googleDriveConnector.listDocuments(
'token',
{ maxFiles: '1' },
undefined,
syncContext
)
const second = await googleDriveConnector.listDocuments(
'token',
{ maxFiles: '1' },
first.nextCursor,
syncContext
)
expect(first.documents[0].skippedReason).toBeDefined()
expect(first.hasMore).toBe(true)
expect(second.documents.map((document) => document.externalId)).toEqual(['indexable'])
expect(syncContext.totalDocsFetched).toBe(1)
})
it('makes an incomplete cross-corpus search non-authoritative', async () => {
mockFetch.mockResolvedValueOnce(
jsonResponse({ files: [fileMetadata()], incompleteSearch: true })
)
const syncContext: Record<string, unknown> = {}
const result = await googleDriveConnector.listDocuments('token', {}, undefined, syncContext)
expect(result.documents.map((document) => document.externalId)).toEqual([FILE_ID])
expect(result.reconciliationSafe).toBe(false)
expect(syncContext.listingCapped).toBe(true)
})
it('rejects a malformed successful file-list envelope', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({}))
await expect(googleDriveConnector.listDocuments('token', {}, undefined, {})).rejects.toThrow(
'Google Drive API returned malformed file-list metadata'
)
})
it('accepts a discriminator-only empty file-list envelope', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ kind: 'drive#fileList' }))
await expect(
googleDriveConnector.listDocuments('token', {}, undefined, {})
).resolves.toMatchObject({ documents: [], hasMore: false })
})
it.each([
{ files: [{ name: 'Missing ID', mimeType: 'text/plain', modifiedTime: '2026-01-01' }] },
{ files: [], nextPageToken: 123 },
{ files: [], incompleteSearch: 'true' },
])('rejects malformed file-list metadata', async (body) => {
mockFetch.mockResolvedValueOnce(jsonResponse(body))
await expect(googleDriveConnector.listDocuments('token', {}, undefined, {})).rejects.toThrow(
'Google Drive API returned malformed file-list metadata'
)
})
it.each(['1.5', 'Infinity', 1.5, Number.POSITIVE_INFINITY])(
'rejects invalid persisted maxFiles %s before listing from Drive',
async (maxFiles) => {
await expect(googleDriveConnector.listDocuments('token', { maxFiles })).rejects.toThrow(
'Max files must be a positive safe integer, or 0 for unlimited'
)
expect(mockFetch).not.toHaveBeenCalled()
}
)
it.each([undefined, null, '', ' ', 0, '0'])(
'keeps omitted or explicit unlimited maxFiles %s valid at runtime',
async (maxFiles) => {
mockFetch.mockResolvedValueOnce(jsonResponse({ files: [] }))
await expect(
googleDriveConnector.listDocuments('token', { maxFiles })
).resolves.toMatchObject({ documents: [], hasMore: false })
expect(String(mockFetch.mock.calls[0][0])).toContain('pageSize=100')
}
)
it('uses a valid persisted maxFiles cap at runtime', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ files: [] }))
await googleDriveConnector.listDocuments('token', { maxFiles: '25' })
expect(String(mockFetch.mock.calls[0][0])).toContain('pageSize=25')
})
it.each(['1.5', 'Infinity', 1.5, Number.POSITIVE_INFINITY])(
'rejects invalid maxFiles %s during validation without calling Drive',
async (maxFiles) => {
await expect(googleDriveConnector.validateConfig('token', { maxFiles })).resolves.toEqual({
valid: false,
error: 'Max files must be a positive safe integer, or 0 for unlimited',
})
expect(mockFetch).not.toHaveBeenCalled()
}
)
})
+284 -124
View File
@@ -1,6 +1,18 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
import { isPlainRecord } from '@sim/utils/object'
import {
attachRetryHeaders,
isRetryableError,
type RetryOptions,
resolveRetryDelayMs,
retryWithExponentialBackoff,
VALIDATE_RETRY_OPTIONS,
} from '@/lib/knowledge/documents/utils'
import {
GoogleDriveApiError,
readGoogleDriveApiError,
} from '@/connectors/google-drive/google-drive-errors'
import { googleDriveConnectorMeta } from '@/connectors/google-drive/meta'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import {
@@ -8,20 +20,25 @@ import {
CONNECTOR_MAX_FILE_BYTES,
ConnectorFileTooLargeError,
htmlToPlainText,
isSkippedDocument,
joinTagArray,
markSkipped,
parseMultiValue,
parseOptionalUnlimitedSafeInteger,
parseTagDate,
readBodyWithLimit,
sizeLimitSkipReason,
stubOrSkipBySize,
takeIndexableWithinCap,
} from '@/connectors/utils'
const logger = createLogger('GoogleDriveConnector')
const GOOGLE_WORKSPACE_MIME_TYPES: Record<string, string> = {
const XLSX_MIME_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
const GOOGLE_WORKSPACE_EXPORTS: Record<string, string> = {
'application/vnd.google-apps.document': 'text/plain',
'application/vnd.google-apps.spreadsheet': 'text/csv',
'application/vnd.google-apps.spreadsheet': XLSX_MIME_TYPE,
'application/vnd.google-apps.presentation': 'text/plain',
}
@@ -37,49 +54,88 @@ const SUPPORTED_TEXT_MIME_TYPES = [
// Google Drive's `files.export` API rejects exports over 10 MB (exportSizeLimitExceeded),
// so this is a hard external limit for Google Workspace docs — not the connector cap.
const MAX_EXPORT_SIZE = 10 * 1024 * 1024
const MAX_FILES_VALIDATION_ERROR = 'Max files must be a positive safe integer, or 0 for unlimited'
function parseMaxFiles(value: unknown): number {
return parseOptionalUnlimitedSafeInteger(value, MAX_FILES_VALIDATION_ERROR)
}
function googleDriveErrorLogFields(error: unknown): Record<string, unknown> {
if (error instanceof GoogleDriveApiError) {
return {
error: error.message,
status: error.status,
reasons: error.reasons,
}
}
return { error: toError(error).message }
}
function isGoogleWorkspaceFile(mimeType: string): boolean {
return mimeType in GOOGLE_WORKSPACE_MIME_TYPES
return mimeType in GOOGLE_WORKSPACE_EXPORTS
}
function isSupportedTextFile(mimeType: string): boolean {
return SUPPORTED_TEXT_MIME_TYPES.some((t) => mimeType.startsWith(t))
}
/** Retries Google errors whose structured body identifies a transient rejection. */
async function fetchGoogleDriveWithRetry(
url: string,
options: RequestInit,
retryOptions: RetryOptions = {}
): Promise<Response> {
return retryWithExponentialBackoff(
async () => {
const response = await fetch(url, options)
if (response.ok) return response
const error = await readGoogleDriveApiError(response)
attachRetryHeaders(error, response.headers)
const waitMs = resolveRetryDelayMs(response.headers)
if (waitMs !== undefined) error.retryAfterMs = waitMs
throw error
},
{
...retryOptions,
retryCondition: (error) =>
error instanceof GoogleDriveApiError
? error.kind === 'transient' || isRetryableError(error)
: (retryOptions.retryCondition?.(error) ?? isRetryableError(error)),
}
)
}
async function exportGoogleWorkspaceFile(
accessToken: string,
fileId: string,
sourceMimeType: string
): Promise<string> {
const exportMimeType = GOOGLE_WORKSPACE_MIME_TYPES[sourceMimeType]
): Promise<Buffer> {
const exportMimeType = GOOGLE_WORKSPACE_EXPORTS[sourceMimeType]
if (!exportMimeType) {
throw new Error(`Unsupported Google Workspace MIME type: ${sourceMimeType}`)
}
const url = `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}/export?mimeType=${encodeURIComponent(exportMimeType)}`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!response.ok) {
// Google rejects exports over its 10 MB limit with a 403 exportSizeLimitExceeded
// before streaming any bytes — surface that as an oversize skip, not a hard error.
if (response.status === 403) {
const body = await response.text().catch(() => '')
if (body.includes('exportSizeLimitExceeded')) {
throw new ConnectorFileTooLargeError(MAX_EXPORT_SIZE)
}
let response: Response
try {
response = await fetchGoogleDriveWithRetry(url, {
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
})
} catch (error) {
if (error instanceof GoogleDriveApiError && error.kind === 'export_too_large') {
throw new ConnectorFileTooLargeError(MAX_EXPORT_SIZE)
}
throw new Error(`Failed to export file ${fileId}: ${response.status}`)
throw error
}
const buffer = await readBodyWithLimit(response, MAX_EXPORT_SIZE)
if (!buffer) {
throw new ConnectorFileTooLargeError(MAX_EXPORT_SIZE)
}
return buffer.toString('utf8')
return buffer
}
async function downloadTextFile(accessToken: string, fileId: string): Promise<string> {
@@ -88,15 +144,11 @@ async function downloadTextFile(accessToken: string, fileId: string): Promise<st
// metadata fetch in getDocument already does. (`files.export` takes no such param.)
const url = `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}?alt=media&supportsAllDrives=true`
const response = await fetchWithRetry(url, {
const response = await fetchGoogleDriveWithRetry(url, {
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!response.ok) {
throw new Error(`Failed to download file ${fileId}: ${response.status}`)
}
// Stream with a hard byte cap so a file with missing/under-reported listing
// size metadata is never fully buffered into memory. Oversized files raise
// DriveFileTooLargeError so getDocument can surface them as skipped (failed) rows.
@@ -107,23 +159,37 @@ async function downloadTextFile(accessToken: string, fileId: string): Promise<st
return buffer.toString('utf8')
}
async function fetchFileContent(
accessToken: string,
fileId: string,
mimeType: string
): Promise<string> {
if (isGoogleWorkspaceFile(mimeType)) {
return exportGoogleWorkspaceFile(accessToken, fileId, mimeType)
type FilePayload = Pick<ExternalDocument, 'content' | 'mimeType' | 'sourceFile'>
function xlsxFileName(name: string): string {
return name.toLowerCase().endsWith('.xlsx') ? name : `${name}.xlsx`
}
async function fetchFilePayload(accessToken: string, file: DriveFile): Promise<FilePayload> {
if (GOOGLE_WORKSPACE_EXPORTS[file.mimeType]) {
const bytes = await exportGoogleWorkspaceFile(accessToken, file.id, file.mimeType)
if (file.mimeType === 'application/vnd.google-apps.spreadsheet') {
return {
content: '',
mimeType: XLSX_MIME_TYPE,
sourceFile: {
bytes,
fileName: xlsxFileName(file.name || 'Untitled'),
mimeType: XLSX_MIME_TYPE,
},
}
}
return { content: bytes.toString('utf8'), mimeType: 'text/plain' }
}
if (mimeType === 'text/html') {
const html = await downloadTextFile(accessToken, fileId)
return htmlToPlainText(html)
if (file.mimeType === 'text/html') {
const html = await downloadTextFile(accessToken, file.id)
return { content: htmlToPlainText(html), mimeType: 'text/plain' }
}
if (isSupportedTextFile(mimeType)) {
return downloadTextFile(accessToken, fileId)
if (isSupportedTextFile(file.mimeType)) {
return { content: await downloadTextFile(accessToken, file.id), mimeType: 'text/plain' }
}
throw new Error(`Unsupported MIME type for content extraction: ${mimeType}`)
throw new Error(`Unsupported MIME type for content extraction: ${file.mimeType}`)
}
interface DriveFile {
@@ -139,6 +205,74 @@ interface DriveFile {
trashed?: boolean
}
interface DriveFileListResponse {
kind?: string
files?: DriveFile[]
incompleteSearch?: boolean
nextPageToken?: string
}
function parseDriveFileListResponse(
value: unknown
): DriveFileListResponse & { files: DriveFile[] } {
if (!isPlainRecord(value)) {
throw new Error('Google Drive API returned malformed file-list metadata')
}
const rawFiles = value.files
if (rawFiles === undefined && value.kind !== 'drive#fileList') {
throw new Error('Google Drive API returned malformed file-list metadata')
}
if (
rawFiles !== undefined &&
(!Array.isArray(rawFiles) || rawFiles.some((file) => !isDriveFileListItem(file)))
) {
throw new Error('Google Drive API returned malformed file-list metadata')
}
if (
value.nextPageToken !== undefined &&
(typeof value.nextPageToken !== 'string' || value.nextPageToken.length === 0)
) {
throw new Error('Google Drive API returned malformed file-list metadata')
}
if (value.incompleteSearch !== undefined && typeof value.incompleteSearch !== 'boolean') {
throw new Error('Google Drive API returned malformed file-list metadata')
}
return {
kind: typeof value.kind === 'string' ? value.kind : undefined,
files: rawFiles ?? [],
incompleteSearch: value.incompleteSearch === true,
nextPageToken: typeof value.nextPageToken === 'string' ? value.nextPageToken : undefined,
}
}
function isDriveFileMetadata(value: unknown, expectedId: string): value is DriveFile {
return (
isPlainRecord(value) &&
value.id === expectedId &&
typeof value.name === 'string' &&
typeof value.mimeType === 'string' &&
(value.trashed === undefined || typeof value.trashed === 'boolean')
)
}
function isDriveFileListItem(value: unknown): value is DriveFile {
return (
isPlainRecord(value) &&
typeof value.id === 'string' &&
value.id.length > 0 &&
typeof value.name === 'string' &&
typeof value.mimeType === 'string' &&
typeof value.modifiedTime === 'string'
)
}
function parseDriveFileMetadata(value: unknown, expectedId: string): DriveFile {
if (!isDriveFileMetadata(value, expectedId)) {
throw new Error('Google Drive API returned malformed file metadata')
}
return value
}
function buildQuery(sourceConfig: Record<string, unknown>): string {
const parts: string[] = ['trashed = false']
@@ -161,10 +295,7 @@ function buildQuery(sourceConfig: Record<string, unknown>): string {
break
default: {
// Include Google Workspace files + plain text files, exclude folders
const allMimeTypes = [
...Object.keys(GOOGLE_WORKSPACE_MIME_TYPES),
...SUPPORTED_TEXT_MIME_TYPES,
]
const allMimeTypes = [...Object.keys(GOOGLE_WORKSPACE_EXPORTS), ...SUPPORTED_TEXT_MIME_TYPES]
parts.push(`(${allMimeTypes.map((t) => `mimeType = '${t}'`).join(' or ')})`)
break
}
@@ -174,6 +305,14 @@ function buildQuery(sourceConfig: Record<string, unknown>): string {
}
function fileToStub(file: DriveFile): ExternalDocument {
/**
* Sheets moved from a first-sheet-only CSV export to the complete XLSX source.
* The namespace forces one rehydration for existing rows whose old hash would
* otherwise preserve embeddings that omit every sheet after the first.
*/
const hashNamespace =
file.mimeType === 'application/vnd.google-apps.spreadsheet' ? 'gdrive:v2' : 'gdrive'
return {
externalId: file.id,
title: file.name || 'Untitled',
@@ -181,7 +320,7 @@ function fileToStub(file: DriveFile): ExternalDocument {
contentDeferred: true,
mimeType: 'text/plain',
sourceUrl: file.webViewLink || `https://drive.google.com/file/d/${file.id}/view`,
contentHash: `gdrive:${file.id}:${file.modifiedTime ?? ''}`,
contentHash: `${hashNamespace}:${file.id}:${file.modifiedTime ?? ''}`,
metadata: {
originalMimeType: file.mimeType,
modifiedTime: file.modifiedTime,
@@ -205,7 +344,7 @@ export const googleDriveConnector: ConnectorConfig = {
const query = buildQuery(sourceConfig)
const pageSize = 100
const maxFiles = sourceConfig.maxFiles ? Number(sourceConfig.maxFiles) : 0
const maxFiles = parseMaxFiles(sourceConfig.maxFiles)
const previouslyFetched = (syncContext?.totalDocsFetched as number) ?? 0
if (maxFiles > 0 && previouslyFetched >= maxFiles) {
@@ -220,7 +359,7 @@ export const googleDriveConnector: ConnectorConfig = {
pageSize: String(effectivePageSize),
orderBy: 'modifiedTime desc',
fields:
'nextPageToken,incompleteSearch,files(id,name,mimeType,modifiedTime,createdTime,webViewLink,owners,size,starred)',
'kind,nextPageToken,incompleteSearch,files(id,name,mimeType,modifiedTime,createdTime,webViewLink,owners,size,starred)',
supportsAllDrives: 'true',
includeItemsFromAllDrives: 'true',
})
@@ -233,25 +372,22 @@ export const googleDriveConnector: ConnectorConfig = {
logger.info('Listing Google Drive files', { query, cursor: cursor ?? 'initial' })
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Failed to list Google Drive files', {
status: response.status,
error: errorText,
let response: Response
try {
response = await fetchGoogleDriveWithRetry(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
})
throw new Error(`Failed to list Google Drive files: ${response.status}`)
} catch (error) {
logger.error('Failed to list Google Drive files', googleDriveErrorLogFields(error))
throw error
}
const data = await response.json()
const files = (data.files || []) as DriveFile[]
const data = parseDriveFileListResponse(await response.json())
const files = data.files
/**
* Drive sets `incompleteSearch` when it could not search every corpus (it
@@ -261,17 +397,24 @@ export const googleDriveConnector: ConnectorConfig = {
*/
const incompleteSearch = data.incompleteSearch === true
const documents = files
const pageDocuments = files
.filter((f) => isGoogleWorkspaceFile(f.mimeType) || isSupportedTextFile(f.mimeType))
.map((f) =>
stubOrSkipBySize(fileToStub(f), Number(f.size) || undefined, CONNECTOR_MAX_FILE_BYTES)
)
const totalFetched = previouslyFetched + documents.length
if (syncContext) syncContext.totalDocsFetched = totalFetched
const hitLimit = maxFiles > 0 && totalFetched >= maxFiles
const page = takeIndexableWithinCap(
pageDocuments,
isSkippedDocument,
maxFiles,
previouslyFetched
)
const nextPageToken = data.nextPageToken as string | undefined
const totalFetched = previouslyFetched + page.indexableCount
if (syncContext) syncContext.totalDocsFetched = totalFetched
const hitLimit = page.capReached
const nextPageToken = data.nextPageToken
/**
* Suppress deletion reconciliation only when the listing really is partial.
@@ -284,9 +427,10 @@ export const googleDriveConnector: ConnectorConfig = {
}
return {
documents,
documents: page.documents,
nextCursor: hitLimit ? undefined : nextPageToken,
hasMore: hitLimit ? false : Boolean(nextPageToken),
reconciliationSafe: incompleteSearch ? false : undefined,
}
},
@@ -299,42 +443,54 @@ export const googleDriveConnector: ConnectorConfig = {
'id,name,mimeType,modifiedTime,createdTime,webViewLink,owners,size,starred,trashed'
const url = `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(externalId)}?fields=${encodeURIComponent(fields)}&supportsAllDrives=true`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
})
if (!response.ok) {
if (response.status === 404) return null
throw new Error(`Failed to get Google Drive file: ${response.status}`)
let response: Response
try {
response = await fetchGoogleDriveWithRetry(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
})
} catch (error) {
if (!(error instanceof GoogleDriveApiError)) throw error
if (error.kind === 'not_found') return null
throw error
}
const file = (await response.json()) as DriveFile
const file = parseDriveFileMetadata(await response.json(), externalId)
if (file.trashed) return null
/**
* Mirrors the listing filter: a file re-uploaded under an unextractable type between
* listing and hydration has no content, which is an absence rather than a fetch
* failure. Returning null keeps it from being retried as a failure every sync.
* Mirrors the listing filter. The marker distinguishes a successfully
* verified unindexable file from an ambiguous null hydration.
*/
if (!isGoogleWorkspaceFile(file.mimeType) && !isSupportedTextFile(file.mimeType)) {
logger.info('Google Drive file has no extractable text type', {
fileId: file.id,
mimeType: file.mimeType,
})
return null
return {
...markSkipped(fileToStub(file), 'File is no longer an indexable document'),
skippedExistingDisposition: 'replace',
}
}
try {
const content = await fetchFileContent(accessToken, file.id, file.mimeType)
if (!content.trim()) return null
const payload = await fetchFilePayload(accessToken, file)
if (!payload.content.trim() && !payload.sourceFile?.bytes.length) {
return {
...markSkipped(
{ ...fileToStub(file), ...payload },
'Document contains no extractable text'
),
skippedExistingDisposition: 'replace',
}
}
const stub = fileToStub(file)
return { ...stub, content, contentDeferred: false }
return { ...stub, ...payload, contentDeferred: false }
} catch (error) {
if (error instanceof ConnectorFileTooLargeError) {
logger.info('Skipping oversized Google Drive file', { fileId: file.id, name: file.name })
@@ -347,7 +503,7 @@ export const googleDriveConnector: ConnectorConfig = {
*/
const err = toError(error)
logger.warn(`Failed to fetch content for file: ${file.name} (${file.id})`, {
error: err.message,
...googleDriveErrorLogFields(err),
})
throw err
}
@@ -358,41 +514,42 @@ export const googleDriveConnector: ConnectorConfig = {
sourceConfig: Record<string, unknown>
): Promise<{ valid: boolean; error?: string }> => {
const folderIds = parseMultiValue(sourceConfig.folderId)
const maxFiles = sourceConfig.maxFiles as string | undefined
if (maxFiles && (Number.isNaN(Number(maxFiles)) || Number(maxFiles) <= 0)) {
return { valid: false, error: 'Max files must be a positive number' }
}
// Verify access to Drive API
try {
parseMaxFiles(sourceConfig.maxFiles)
if (folderIds.length > 0) {
// Verify each folder exists, is accessible, and is actually a folder
for (const folderId of folderIds) {
const url = `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(folderId)}?fields=id,name,mimeType&supportsAllDrives=true`
const response = await fetchWithRetry(
url,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
let response: Response
try {
response = await fetchGoogleDriveWithRetry(
url,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
},
},
VALIDATE_RETRY_OPTIONS
)
if (!response.ok) {
if (response.status === 404) {
VALIDATE_RETRY_OPTIONS
)
} catch (error) {
if (error instanceof GoogleDriveApiError) {
if (error.kind === 'not_found') {
return {
valid: false,
error: `Folder "${folderId}" not found. Check the folder ID and permissions.`,
}
}
return {
valid: false,
error: `Folder "${folderId}" not found. Check the folder ID and permissions.`,
error: `Failed to access folder "${folderId}": ${error.message}`,
}
}
return {
valid: false,
error: `Failed to access folder "${folderId}": ${response.status}`,
}
throw error
}
const folder = await response.json()
@@ -404,20 +561,23 @@ export const googleDriveConnector: ConnectorConfig = {
// Verify basic Drive access by listing one file
const url =
'https://www.googleapis.com/drive/v3/files?pageSize=1&fields=files(id)&supportsAllDrives=true&includeItemsFromAllDrives=true'
const response = await fetchWithRetry(
url,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
try {
await fetchGoogleDriveWithRetry(
url,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
},
},
VALIDATE_RETRY_OPTIONS
)
if (!response.ok) {
return { valid: false, error: `Failed to access Google Drive: ${response.status}` }
VALIDATE_RETRY_OPTIONS
)
} catch (error) {
if (error instanceof GoogleDriveApiError) {
return { valid: false, error: `Failed to access Google Drive: ${error.message}` }
}
throw error
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ export const notionConnectorMeta: ConnectorMeta = {
options: [
{ label: 'Entire workspace', id: 'workspace' },
{ label: 'Specific database', id: 'database' },
{ label: 'Specific page (and children)', id: 'page' },
{ label: 'Specific page (and direct child pages)', id: 'page' },
],
},
{
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+171 -4
View File
@@ -7,11 +7,16 @@ const { mockFetchWithRetry } = vi.hoisted(() => ({ mockFetchWithRetry: vi.fn() }
vi.mock('@/lib/knowledge/documents/utils', () => ({
fetchWithRetry: mockFetchWithRetry,
readBoundedHttpErrorBody: async (response: Response) => response.text(),
VALIDATE_RETRY_OPTIONS: {},
}))
vi.mock('@/components/icons', () => ({ MicrosoftOneDriveIcon: () => null }))
import { onedriveConnector } from '@/connectors/onedrive/onedrive'
import {
encodeMicrosoftGraphTraversalCursor,
MICROSOFT_GRAPH_MAX_PENDING_FOLDERS,
} from '@/connectors/utils'
const GRAPH = 'https://graph.microsoft.com/v1.0'
@@ -52,15 +57,32 @@ function mockGraph(routes: Record<string, GraphRoute>) {
return requested
}
const ROOT_URL = `${GRAPH}/me/drive/root/children?$top=200&$select=id,name,webUrl,size,file,folder,lastModifiedDateTime,createdBy,parentReference`
const ROOT_URL = `${GRAPH}/me/drive/root/children?$top=200&$select=id,name,webUrl,size,file,folder,package,remoteItem,lastModifiedDateTime,createdBy,parentReference`
const childrenUrl = (id: string) =>
`${GRAPH}/me/drive/items/${id}/children?$top=200&$select=id,name,webUrl,size,file,folder,lastModifiedDateTime,createdBy,parentReference`
`${GRAPH}/me/drive/items/${id}/children?$top=200&$select=id,name,webUrl,size,file,folder,package,remoteItem,lastModifiedDateTime,createdBy,parentReference`
describe('onedrive listDocuments', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('rejects a malformed successful list envelope', async () => {
mockGraph({ [ROOT_URL]: { body: {} } })
await expect(onedriveConnector.listDocuments('token', {}, undefined, {})).rejects.toThrow(
'Microsoft Graph returned malformed OneDrive list metadata'
)
})
it.each([
{ value: [{ id: 'f1', name: 'Missing facet' }] },
{ value: [], '@odata.nextLink': 'https://evil.example/items' },
])('rejects ambiguous or unsafe list metadata', async (body) => {
mockGraph({ [ROOT_URL]: { body } })
await expect(onedriveConnector.listDocuments('token', {}, undefined, {})).rejects.toThrow()
})
it('walks nested folders within a single call', async () => {
const requested = mockGraph({
[ROOT_URL]: { body: { value: [file('f1', 'a.txt'), folder('dir1', 'dir1')] } },
@@ -161,6 +183,33 @@ describe('onedrive listDocuments', () => {
expect(syncContext.listingCapped).toBe(true)
})
it('does not retain irrelevant folders after maxFiles has stopped traversal', async () => {
mockGraph({
[ROOT_URL]: { body: { value: [file('f1', 'a.txt'), folder('overflow', 'overflow')] } },
})
const cursor = encodeMicrosoftGraphTraversalCursor(
{
folderStack: Array.from(
{ length: MICROSOFT_GRAPH_MAX_PENDING_FOLDERS },
(_, index) => `pending-${index}`
),
},
'OneDrive'
)
const syncContext: Record<string, unknown> = {}
const result = await onedriveConnector.listDocuments(
'token',
{ maxFiles: '1' },
cursor,
syncContext
)
expect(result.documents.map((document) => document.externalId)).toEqual(['f1'])
expect(result.hasMore).toBe(false)
expect(syncContext.listingCapped).toBe(true)
})
it('resumes from the cursor when the per-call request budget is exhausted', async () => {
const routes: Record<string, GraphRoute> = {
[ROOT_URL]: {
@@ -186,7 +235,7 @@ describe('onedrive listDocuments', () => {
})
it('encodes the configured folder path', async () => {
const url = `${GRAPH}/me/drive/root:/My%20Docs/Q1%20%26%20Q2:/children?$top=200&$select=id,name,webUrl,size,file,folder,lastModifiedDateTime,createdBy,parentReference`
const url = `${GRAPH}/me/drive/root:/My%20Docs/Q1%20%26%20Q2:/children?$top=200&$select=id,name,webUrl,size,file,folder,package,remoteItem,lastModifiedDateTime,createdBy,parentReference`
const requested = mockGraph({ [url]: { body: { value: [] } } })
await onedriveConnector.listDocuments(
@@ -198,6 +247,47 @@ describe('onedrive listDocuments', () => {
expect(requested[0]).toBe(url)
})
it.each(['1.5', 'Infinity'])(
'rejects invalid maxFiles %s before calling Graph',
async (maxFiles) => {
const requested = mockGraph({})
await expect(
onedriveConnector.listDocuments('token', { maxFiles }, undefined, {})
).rejects.toThrow(/positive safe integer/)
expect(requested).toHaveLength(0)
}
)
})
describe('onedrive validateConfig', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it.each(['1.5', 'Infinity'])(
'rejects invalid maxFiles %s without calling Graph',
async (maxFiles) => {
const requested = mockGraph({})
await expect(onedriveConnector.validateConfig!('token', { maxFiles })).resolves.toEqual({
valid: false,
error: 'Max files must be a positive safe integer, or 0 for unlimited',
})
expect(requested).toHaveLength(0)
}
)
it('accepts a valid integer maxFiles', async () => {
const validateRootUrl = `${GRAPH}/me/drive/root/children?$top=1&$select=id`
const requested = mockGraph({ [validateRootUrl]: { body: { value: [] } } })
await expect(onedriveConnector.validateConfig!('token', { maxFiles: '25' })).resolves.toEqual({
valid: true,
})
expect(requested).toEqual([validateRootUrl])
})
})
describe('onedrive getDocument', () => {
@@ -211,11 +301,56 @@ describe('onedrive getDocument', () => {
expect(doc).toBeNull()
})
it.each([{}, { id: 'f1', name: 'Missing facet' }, file('different', 'a.txt')])(
'rejects malformed metadata instead of replacing retained content',
async (metadata) => {
mockGraph({
[`${GRAPH}/me/drive/items/f1?$select=id,name,webUrl,size,file,folder,package,remoteItem,lastModifiedDateTime,createdBy,parentReference`]:
{
body: metadata,
},
})
await expect(onedriveConnector.getDocument!('token', {}, 'f1')).rejects.toThrow(
'Microsoft Graph returned malformed OneDrive item metadata'
)
}
)
it('authoritatively skips a listed file that changed to a folder', async () => {
mockGraph({
[`${GRAPH}/me/drive/items/f1?$select=id,name,webUrl,size,file,folder,package,remoteItem,lastModifiedDateTime,createdBy,parentReference`]:
{
body: folder('f1', 'Former document'),
},
})
await expect(onedriveConnector.getDocument!('token', {}, 'f1')).resolves.toMatchObject({
content: '',
skippedReason: 'File is no longer an indexable document',
skippedExistingDisposition: 'replace',
})
})
it('authoritatively skips a listed file that changed to a package', async () => {
mockGraph({
[`${GRAPH}/me/drive/items/f1?$select=id,name,webUrl,size,file,folder,package,remoteItem,lastModifiedDateTime,createdBy,parentReference`]:
{
body: { id: 'f1', name: 'Former document', package: { type: 'oneNote' } },
},
})
await expect(onedriveConnector.getDocument!('token', {}, 'f1')).resolves.toMatchObject({
skippedExistingDisposition: 'replace',
skippedReason: 'File is no longer an indexable document',
})
})
it('produces the same contentHash as the listing stub', async () => {
const item = file('f1', 'a.txt')
mockGraph({
[ROOT_URL]: { body: { value: [item] } },
[`${GRAPH}/me/drive/items/f1?$select=id,name,webUrl,size,file,folder,lastModifiedDateTime,createdBy,parentReference`]:
[`${GRAPH}/me/drive/items/f1?$select=id,name,webUrl,size,file,folder,package,remoteItem,lastModifiedDateTime,createdBy,parentReference`]:
{ body: item },
})
@@ -227,6 +362,7 @@ describe('onedrive getDocument', () => {
ok: true,
status: 200,
body: null,
headers: new Headers({ 'content-length': '5' }),
arrayBuffer: async () => new TextEncoder().encode('hello').buffer,
} as unknown as Response
}
@@ -244,4 +380,35 @@ describe('onedrive getDocument', () => {
expect(fetched?.contentDeferred).toBe(false)
expect(fetched?.content).toBe('hello')
})
it('marks an empty file as an authoritative skip', async () => {
const item = file('empty', 'empty.txt', 0)
mockFetchWithRetry.mockImplementation(async (url: string) => {
if (url.endsWith('/content')) {
return {
ok: true,
status: 200,
body: null,
headers: new Headers({ 'content-length': '0' }),
arrayBuffer: async () => new ArrayBuffer(0),
} as unknown as Response
}
return {
ok: true,
status: 200,
json: async () => item,
text: async () => '',
} as unknown as Response
})
const document = await onedriveConnector.getDocument!('token', {}, 'empty')
expect(document).toMatchObject({
externalId: 'empty',
content: '',
contentDeferred: false,
skippedReason: 'Document contains no extractable text',
skippedExistingDisposition: 'replace',
})
})
})
+70 -49
View File
@@ -1,17 +1,29 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
import {
fetchWithRetry,
readBoundedHttpErrorBody,
VALIDATE_RETRY_OPTIONS,
} from '@/lib/knowledge/documents/utils'
import { onedriveConnectorMeta } from '@/connectors/onedrive/meta'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import {
appendPendingMicrosoftGraphFolders,
assertMicrosoftGraphNextLink,
CONNECTOR_MAX_FILE_BYTES,
ConnectorFileTooLargeError,
connectorFileExtension,
decodeMicrosoftGraphTraversalCursor,
encodeMicrosoftGraphTraversalCursor,
extractConnectorText,
hasIndexablePayload,
isIndexableConnectorFile,
isMicrosoftGraphDriveItem,
isSkippedDocument,
type MicrosoftGraphTraversalState,
markSkipped,
parseMicrosoftGraphDriveItemList,
parseOptionalUnlimitedSafeInteger,
parseTagDate,
pipelineParsedMimeType,
readBodyWithLimit,
@@ -34,7 +46,8 @@ const GRAPH_BASE_URL = `${GRAPH_API_ORIGIN}/v1.0`
* The exact driveItem fields the stub is built from. Graph returns the full
* driveItem otherwise, which is an order of magnitude larger per item.
*/
const ITEM_SELECT = 'id,name,webUrl,size,file,folder,lastModifiedDateTime,createdBy,parentReference'
const ITEM_SELECT =
'id,name,webUrl,size,file,folder,package,remoteItem,lastModifiedDateTime,createdBy,parentReference'
/**
* Requested page size for a children collection, matching Graph's own default.
@@ -59,11 +72,20 @@ const PAGE_SIZE = 200
*/
const MAX_LIST_REQUESTS_PER_CALL = 25
function parseMaxFiles(value: unknown): number {
return parseOptionalUnlimitedSafeInteger(
value,
'Max files must be a positive safe integer, or 0 for unlimited'
)
}
interface OneDriveItem {
id: string
name: string
file?: { mimeType: string }
folder?: { childCount: number }
package?: Record<string, unknown>
remoteItem?: Record<string, unknown>
size?: number
webUrl?: string
lastModifiedDateTime?: string
@@ -71,9 +93,15 @@ interface OneDriveItem {
parentReference?: { path?: string }
}
interface OneDriveListResponse {
value: OneDriveItem[]
'@odata.nextLink'?: string
function isOneDriveItemMetadata(value: unknown, expectedId: string): value is OneDriveItem {
return isMicrosoftGraphDriveItem(value) && value.id === expectedId
}
function parseOneDriveItemMetadata(value: unknown, expectedId: string): OneDriveItem {
if (!isOneDriveItemMetadata(value, expectedId)) {
throw new Error('Microsoft Graph returned malformed OneDrive item metadata')
}
return value
}
/**
@@ -170,43 +198,21 @@ function buildListUrl(folderPath: string | undefined, folderId: string | undefin
/**
* Asserts a paging URL points at Microsoft Graph before it is followed with the
* bearer token in the `Authorization` header. The `@odata.nextLink` this connector
* follows is persisted into the sync cursor, so it round-trips through storage
* rather than arriving straight off a TLS response — a tampered cursor must never
* be able to redirect the access token to a third-party host. Mirrors
* `assertGraphNextPageUrl` used by the Graph tool routes.
* bearer token in the `Authorization` header. Provider-controlled continuation
* state must never be able to redirect the access token to a third-party host.
* Mirrors `assertGraphNextPageUrl` used by the Graph tool routes.
*/
function assertGraphNextLink(nextLink: string): string {
const url = new URL(nextLink.trim())
if (url.origin !== GRAPH_API_ORIGIN) {
throw new Error('Refusing to follow a non-Microsoft Graph @odata.nextLink')
}
return url.toString()
return assertMicrosoftGraphNextLink(nextLink)
}
/**
* Depth-first traversal position carried across `listDocuments` calls.
*/
interface OneDriveTraversalState {
/** Absolute `@odata.nextLink` for the current folder's next page, if any. */
nextLink?: string
/** Item id of the folder being listed; `undefined` means the configured root. */
currentFolder?: string
/** Subfolder ids discovered but not yet listed. */
folderStack: string[]
}
type OneDriveTraversalState = MicrosoftGraphTraversalState
function decodeCursor(cursor: string): OneDriveTraversalState {
try {
const parsed = JSON.parse(cursor) as Partial<OneDriveTraversalState>
return {
nextLink: typeof parsed.nextLink === 'string' ? parsed.nextLink : undefined,
currentFolder: typeof parsed.currentFolder === 'string' ? parsed.currentFolder : undefined,
folderStack: Array.isArray(parsed.folderStack) ? parsed.folderStack : [],
}
} catch {
return { folderStack: [] }
}
return decodeMicrosoftGraphTraversalCursor(cursor, 'OneDrive')
}
export const onedriveConnector: ConnectorConfig = {
@@ -220,8 +226,7 @@ export const onedriveConnector: ConnectorConfig = {
): Promise<ExternalDocumentList> => {
const folderPath = sourceConfig.folderPath as string | undefined
const parsedMaxFiles = Number(sourceConfig.maxFiles)
const maxFiles = Number.isFinite(parsedMaxFiles) && parsedMaxFiles > 0 ? parsedMaxFiles : 0
const maxFiles = parseMaxFiles(sourceConfig.maxFiles)
const state: OneDriveTraversalState = cursor ? decodeCursor(cursor) : { folderStack: [] }
@@ -253,7 +258,7 @@ export const onedriveConnector: ConnectorConfig = {
})
if (!response.ok) {
const errorText = await response.text()
const errorText = await readBoundedHttpErrorBody(response)
logger.error('Failed to list OneDrive files', {
status: response.status,
error: errorText,
@@ -261,10 +266,11 @@ export const onedriveConnector: ConnectorConfig = {
throw new Error(`Failed to list OneDrive files: ${response.status}`)
}
const data = (await response.json()) as OneDriveListResponse
const items = data.value || []
const data = parseMicrosoftGraphDriveItemList(await response.json(), 'OneDrive')
const items = data.value as OneDriveItem[]
const files: OneDriveItem[] = []
const subfolders: string[] = []
/**
* Extensions this connector cannot index, tallied per page. A folder of
* unsupported files otherwise syncs as "success, 0 documents", which reads
@@ -276,7 +282,7 @@ export const onedriveConnector: ConnectorConfig = {
for (const item of items) {
if (item.folder) {
state.folderStack.push(item.id)
subfolders.push(item.id)
} else if (item.file) {
if (isIndexableConnectorFile(item.name)) {
// Keep oversized files; they are surfaced as skipped (failed) docs below.
@@ -305,7 +311,7 @@ export const onedriveConnector: ConnectorConfig = {
documents.push(...take.documents)
totalFetched += take.indexableCount
const nextLink = data['@odata.nextLink']
const nextLink = data.nextLink
if (take.capReached) {
done = true
@@ -319,10 +325,15 @@ export const onedriveConnector: ConnectorConfig = {
* block deletion reconciliation for a complete listing.
*/
cappedWithItemsLeft =
take.documents.length < stubs.length || Boolean(nextLink) || state.folderStack.length > 0
take.documents.length < stubs.length ||
Boolean(nextLink) ||
subfolders.length > 0 ||
state.folderStack.length > 0
break
}
appendPendingMicrosoftGraphFolders(state.folderStack, subfolders, 'OneDrive')
if (nextLink) {
state.nextLink = nextLink
continue
@@ -353,7 +364,7 @@ export const onedriveConnector: ConnectorConfig = {
*/
return {
documents,
nextCursor: JSON.stringify(state),
nextCursor: encodeMicrosoftGraphTraversalCursor(state, 'OneDrive'),
hasMore: true,
}
},
@@ -378,13 +389,23 @@ export const onedriveConnector: ConnectorConfig = {
throw new Error(`Failed to get OneDrive file: ${response.status}`)
}
const item = (await response.json()) as OneDriveItem
const item = parseOneDriveItemMetadata(await response.json(), externalId)
if (!item.file || !isIndexableConnectorFile(item.name)) return null
if (!item.file || !isIndexableConnectorFile(item.name)) {
return {
...markSkipped(fileToStub(item), 'File is no longer an indexable document'),
skippedExistingDisposition: 'replace',
}
}
try {
const payload = await fetchFilePayload(accessToken, item.id, item.name)
if (!hasIndexablePayload(payload)) return null
if (!hasIndexablePayload(payload)) {
return {
...markSkipped(fileToStub(item), 'Document contains no extractable text'),
skippedExistingDisposition: 'replace',
}
}
const stub = fileToStub(item)
return { ...stub, ...payload, contentDeferred: false }
@@ -410,10 +431,10 @@ export const onedriveConnector: ConnectorConfig = {
sourceConfig: Record<string, unknown>
): Promise<{ valid: boolean; error?: string }> => {
const folderPath = sourceConfig.folderPath as string | undefined
const maxFiles = sourceConfig.maxFiles as string | undefined
if (maxFiles && (Number.isNaN(Number(maxFiles)) || Number(maxFiles) <= 0)) {
return { valid: false, error: 'Max files must be a positive number' }
try {
parseMaxFiles(sourceConfig.maxFiles)
} catch (error) {
return { valid: false, error: toError(error).message }
}
try {
@@ -7,6 +7,7 @@ const { mockFetchWithRetry } = vi.hoisted(() => ({ mockFetchWithRetry: vi.fn() }
vi.mock('@/lib/knowledge/documents/utils', () => ({
fetchWithRetry: mockFetchWithRetry,
readBoundedHttpErrorBody: async (response: Response) => response.text(),
VALIDATE_RETRY_OPTIONS: {},
}))
vi.mock('@/components/icons', () => ({ MicrosoftSharepointIcon: () => null }))
@@ -17,6 +18,11 @@ import {
serverRelativePathFromUrl,
sharepointConnector,
} from '@/connectors/sharepoint/sharepoint'
import {
appendPendingMicrosoftGraphFolders,
encodeMicrosoftGraphTraversalCursor,
MICROSOFT_GRAPH_MAX_PENDING_FOLDERS,
} from '@/connectors/utils'
const GRAPH = 'https://graph.microsoft.com/v1.0'
const SITE_ID = 'contoso.sharepoint.com,site-guid,web-guid'
@@ -46,14 +52,17 @@ function mockGraph(routes: Record<string, GraphRoute>) {
requested.push(url)
const route = routes[url] ?? { status: 404 }
const status = route.status ?? 200
const responseBytes = Buffer.from(
route.raw ? String(route.body ?? '') : JSON.stringify(route.body ?? {})
)
return {
ok: status >= 200 && status < 300,
status,
headers: new Headers({ 'content-length': String(responseBytes.byteLength) }),
json: async () => route.body,
text: async () => JSON.stringify(route.body ?? {}),
/** `readBodyWithLimit` falls back to this when there is no stream body. */
arrayBuffer: async () =>
Buffer.from(route.raw ? String(route.body ?? '') : JSON.stringify(route.body ?? {})),
arrayBuffer: async () => responseBytes,
} as unknown as Response
})
return requested
@@ -339,7 +348,7 @@ describe('resolveFolderTarget', () => {
})
const ITEM_SELECT =
'id,name,webUrl,size,file,folder,lastModifiedDateTime,createdDateTime,createdBy,parentReference'
'id,name,webUrl,size,file,folder,package,remoteItem,lastModifiedDateTime,createdDateTime,createdBy,parentReference'
/** File-shaped drive item for children listings. */
function file(id: string, name: string) {
@@ -416,6 +425,33 @@ describe('listDocuments', () => {
expect(syncContext.listingCapped).toBeUndefined()
})
it('does not retain irrelevant folders after maxFiles has stopped traversal', async () => {
mockGraph(
childrenRoute(DEFAULT_DRIVE_ID, null, [file('f1', 'a.txt'), folder('overflow', 'Overflow')])
)
const cursor = encodeMicrosoftGraphTraversalCursor(
{
folderStack: Array.from(
{ length: MICROSOFT_GRAPH_MAX_PENDING_FOLDERS },
(_, index) => `pending-${index}`
),
},
'SharePoint'
)
const syncContext = listContext()
const result = await sharepointConnector.listDocuments(
'token',
{ siteUrl: SITE_URL, maxFiles: '1' },
cursor,
syncContext
)
expect(result.documents.map((document) => document.externalId)).toEqual(['f1'])
expect(result.hasMore).toBe(false)
expect(syncContext.listingCapped).toBe(true)
})
/**
* The reported failure: a document library of Office SOPs synced as
* "success, 0 documents" because the listing filter accepted only plain text,
@@ -465,6 +501,78 @@ describe('listDocuments', () => {
expect(result.documents[0].contentHash).toBe('sharepoint:f1:2026-01-01T00:00:00Z')
expect(result.documents[0].contentDeferred).toBe(true)
})
it.each(['1.5', 'Infinity'])(
'rejects invalid maxFiles %s before calling Graph',
async (maxFiles) => {
const requested = mockGraph({})
await expect(
sharepointConnector.listDocuments(
'token',
{ siteUrl: SITE_URL, maxFiles },
undefined,
listContext()
)
).rejects.toThrow(/positive safe integer/)
expect(requested).toHaveLength(0)
}
)
})
describe('validateConfig', () => {
it.each(['1.5', 'Infinity'])(
'rejects invalid maxFiles %s without calling Graph',
async (maxFiles) => {
const requested = mockGraph({})
await expect(
sharepointConnector.validateConfig!('token', { siteUrl: SITE_URL, maxFiles })
).resolves.toEqual({
valid: false,
error: 'Max files must be a positive safe integer, or 0 for unlimited',
})
expect(requested).toHaveLength(0)
}
)
it('accepts a valid integer maxFiles', async () => {
const siteRoute = `${GRAPH}/sites/${SITE_URL}`
const requested = mockGraph({
[siteRoute]: { body: { id: SITE_ID, displayName: 'Contoso' } },
...defaultDriveRoute,
})
await expect(
sharepointConnector.validateConfig!('token', { siteUrl: SITE_URL, maxFiles: '25' })
).resolves.toEqual({ valid: true })
expect(requested).toEqual([siteRoute, Object.keys(defaultDriveRoute)[0]])
})
})
describe('SharePoint traversal working-set bound', () => {
it('accepts discovered folders up to the pending-folder ceiling', () => {
const pending = Array.from(
{ length: MICROSOFT_GRAPH_MAX_PENDING_FOLDERS - 2 },
(_, index) => `pending-${index}`
)
appendPendingMicrosoftGraphFolders(pending, ['last-1', 'last-2'], 'SharePoint')
expect(pending).toHaveLength(MICROSOFT_GRAPH_MAX_PENDING_FOLDERS)
})
it('stops before retaining a folder page beyond the ceiling', () => {
const pending = Array.from(
{ length: MICROSOFT_GRAPH_MAX_PENDING_FOLDERS },
(_, index) => `pending-${index}`
)
expect(() => appendPendingMicrosoftGraphFolders(pending, ['overflow'], 'SharePoint')).toThrow(
/Narrow the connector/
)
expect(pending).toHaveLength(MICROSOFT_GRAPH_MAX_PENDING_FOLDERS)
})
})
describe('getDocument content extraction', () => {
@@ -492,6 +600,21 @@ describe('getDocument content extraction', () => {
)
}
it.each([{}, { id: 'f1', name: 'Missing facet' }, file('different', 'a.txt')])(
'rejects malformed metadata instead of replacing retained content',
async (metadata) => {
mockGraph({
[`${GRAPH}/drives/${DEFAULT_DRIVE_ID}/items/f1?$select=${ITEM_SELECT}`]: {
body: metadata,
},
})
await expect(get('f1')).rejects.toThrow(
'Microsoft Graph returned malformed SharePoint item metadata'
)
}
)
/**
* The connector hands an Office document over untouched so the shared pipeline
* parses it — the same path an upload of the same file takes, which is what
@@ -532,6 +655,40 @@ describe('getDocument content extraction', () => {
expect(doc?.sourceFile).toBeUndefined()
expect(doc?.mimeType).toBe('text/plain')
})
it('authoritatively skips a listed file that changed to a folder', async () => {
mockGraph({
[`${GRAPH}/drives/${DEFAULT_DRIVE_ID}/items/folder?$select=${ITEM_SELECT}`]: {
body: folder('folder', 'Former document'),
},
})
await expect(get('folder')).resolves.toMatchObject({
content: '',
skippedReason: 'File is no longer an indexable document',
skippedExistingDisposition: 'replace',
})
})
it('marks an empty file as an authoritative skip', async () => {
const item = file('empty', 'empty.txt')
mockFetchWithRetry.mockImplementation(async (url: string) => {
if (url.endsWith('/content')) return new Response(new Uint8Array(0))
return new Response(JSON.stringify(item), {
headers: { 'content-type': 'application/json' },
})
})
const document = await get('empty')
expect(document).toMatchObject({
externalId: 'empty',
content: '',
contentDeferred: false,
skippedReason: 'Document contains no extractable text',
skippedExistingDisposition: 'replace',
})
})
})
describe('serverRelativePathFromUrl', () => {
+123 -41
View File
@@ -1,17 +1,30 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
import { isPlainRecord } from '@sim/utils/object'
import {
fetchWithRetry,
readBoundedHttpErrorBody,
VALIDATE_RETRY_OPTIONS,
} from '@/lib/knowledge/documents/utils'
import { sharepointConnectorMeta } from '@/connectors/sharepoint/meta'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import {
appendPendingMicrosoftGraphFolders,
assertMicrosoftGraphNextLink,
CONNECTOR_MAX_FILE_BYTES,
ConnectorFileTooLargeError,
connectorFileExtension,
decodeMicrosoftGraphTraversalCursor,
encodeMicrosoftGraphTraversalCursor,
extractConnectorText,
hasIndexablePayload,
isIndexableConnectorFile,
isMicrosoftGraphDriveItem,
isSkippedDocument,
type MicrosoftGraphTraversalState,
markSkipped,
parseMicrosoftGraphDriveItemList,
parseOptionalUnlimitedSafeInteger,
parseTagDate,
pipelineParsedMimeType,
readBodyWithLimit,
@@ -35,7 +48,7 @@ const MAX_LOGGED_SKIPPED_EXTENSIONS = 10
* driveItem otherwise, which is an order of magnitude larger per item.
*/
const ITEM_SELECT =
'id,name,webUrl,size,file,folder,lastModifiedDateTime,createdDateTime,createdBy,parentReference'
'id,name,webUrl,size,file,folder,package,remoteItem,lastModifiedDateTime,createdDateTime,createdBy,parentReference'
/**
* Folder pages listed within a single `listDocuments` call. The sync engine caps
@@ -45,6 +58,22 @@ const ITEM_SELECT =
*/
const MAX_LIST_REQUESTS_PER_CALL = 25
/**
* Maximum breadth retained by the depth-first library walk.
*
* A library page can contribute 200 folders, and the sync engine may request
* 12,500 Graph pages in one run. Without this guard a folder-heavy tenant can
* retain millions of IDs and serialize them into a multi-megabyte cursor before
* yielding one document. Exceeding the bound fails visibly so users can scope
* the connector to a narrower library or folder.
*/
function parseMaxFiles(value: unknown): number {
return parseOptionalUnlimitedSafeInteger(
value,
'Max files must be a positive safe integer, or 0 for unlimited'
)
}
/** Microsoft Graph drive item shape (subset of fields we use). */
interface DriveItem {
id: string
@@ -53,15 +82,23 @@ interface DriveItem {
size?: number
file?: { mimeType?: string }
folder?: { childCount?: number }
package?: Record<string, unknown>
remoteItem?: Record<string, unknown>
lastModifiedDateTime?: string
createdDateTime?: string
createdBy?: { user?: { displayName?: string } }
parentReference?: { path?: string; siteId?: string }
}
interface DriveItemListResponse {
value: DriveItem[]
'@odata.nextLink'?: string
function isDriveItemMetadata(value: unknown, expectedId: string): value is DriveItem {
return isMicrosoftGraphDriveItem(value) && value.id === expectedId
}
function parseDriveItemMetadata(value: unknown, expectedId: string): DriveItem {
if (!isDriveItemMetadata(value, expectedId)) {
throw new Error('Microsoft Graph returned malformed SharePoint item metadata')
}
return value
}
/** Microsoft Graph drive (document library) shape (subset of fields we use). */
@@ -76,6 +113,24 @@ interface DriveListResponse {
'@odata.nextLink'?: string
}
function parseDriveListResponse(value: unknown): DriveListResponse {
if (!isPlainRecord(value) || !Array.isArray(value.value)) {
throw new Error('Microsoft Graph returned malformed SharePoint drive-list metadata')
}
if (
!value.value.every(
(drive) => isPlainRecord(drive) && typeof drive.id === 'string' && drive.id.length > 0
)
) {
throw new Error('Microsoft Graph returned malformed SharePoint drive metadata')
}
const nextLink =
value['@odata.nextLink'] === undefined
? undefined
: assertMicrosoftGraphNextLink(value['@odata.nextLink'])
return { value: value.value as Drive[], ...(nextLink ? { '@odata.nextLink': nextLink } : {}) }
}
/** A configured folder path resolved to a concrete drive and starting folder. */
interface ResolvedFolderTarget {
driveId: string
@@ -94,11 +149,7 @@ type RetryOptions = Parameters<typeof fetchWithRetry>[2]
* party. Mirrors `assertGraphNextPageUrl` used by the Graph tool routes.
*/
function assertGraphUrl(url: string): string {
const parsed = new URL(url.trim())
if (parsed.origin !== GRAPH_API_ORIGIN) {
throw new Error('Refusing to follow a non-Microsoft Graph URL')
}
return parsed.toString()
return assertMicrosoftGraphNextLink(url)
}
/**
@@ -164,7 +215,7 @@ async function resolveSiteId(
const response = await graphGet(url, accessToken, retryOptions)
if (!response.ok) {
const errorText = await response.text()
const errorText = await readBoundedHttpErrorBody(response)
throw new Error(
`Failed to resolve SharePoint site "${siteUrl}": ${response.status} – ${errorText}`
)
@@ -261,7 +312,7 @@ async function listFolderItems(
driveId: string,
folderId?: string,
nextLink?: string
): Promise<DriveItemListResponse> {
): Promise<{ value: DriveItem[]; nextLink?: string }> {
const url =
nextLink ??
(folderId
@@ -271,11 +322,12 @@ async function listFolderItems(
const response = await graphGet(url, accessToken)
if (!response.ok) {
const errorText = await response.text()
const errorText = await readBoundedHttpErrorBody(response)
throw new Error(`Failed to list folder items: ${response.status} – ${errorText}`)
}
return response.json() as Promise<DriveItemListResponse>
const data = parseMicrosoftGraphDriveItemList(await response.json(), 'SharePoint')
return { value: data.value as DriveItem[], nextLink: data.nextLink }
}
/**
@@ -378,12 +430,31 @@ async function listChildFolders(
throw new Error(`Failed to list folder contents: ${response.status}`)
}
const data = (await response.json()) as DriveItemListResponse
for (const item of data.value) {
const rawData: unknown = await response.json()
if (!isPlainRecord(rawData) || !Array.isArray(rawData.value)) {
throw new Error('Microsoft Graph returned malformed SharePoint folder-list metadata')
}
if (
!rawData.value.every(
(item) =>
isPlainRecord(item) &&
typeof item.id === 'string' &&
item.id.length > 0 &&
typeof item.name === 'string' &&
(item.folder === undefined || isPlainRecord(item.folder))
)
) {
throw new Error('Microsoft Graph returned malformed SharePoint folder metadata')
}
const items = rawData.value as DriveItem[]
for (const item of items) {
if (item.folder) folders.push(item)
}
const nextLink = data['@odata.nextLink']
const nextLink =
rawData['@odata.nextLink'] === undefined
? undefined
: assertMicrosoftGraphNextLink(rawData['@odata.nextLink'])
if (!nextLink) break
url = nextLink
}
@@ -598,8 +669,8 @@ async function listSiteDrives(
for (let page = 0; page < MAX_DRIVE_PAGES; page++) {
const response = await graphGet(url, accessToken, retryOptions)
if (!response.ok) break
const data = (await response.json()) as DriveListResponse
drives.push(...(data.value ?? []))
const data = parseDriveListResponse(await response.json())
drives.push(...data.value)
const nextLink = data['@odata.nextLink']
if (!nextLink) break
url = nextLink
@@ -685,21 +756,14 @@ async function buildFolderNotFoundMessage(
* Pagination state encoded as the cursor string.
* We track a stack of folder IDs to traverse plus an optional @odata.nextLink.
*/
interface PaginationState {
/** Folders still to be listed (depth-first) */
folderStack: string[]
/** Current folder being listed (undefined = root) */
currentFolder?: string
/** @odata.nextLink for the current folder page */
nextLink?: string
}
type PaginationState = MicrosoftGraphTraversalState
function encodeCursor(state: PaginationState): string {
return Buffer.from(JSON.stringify(state)).toString('base64')
return encodeMicrosoftGraphTraversalCursor(state, 'SharePoint')
}
function decodeCursor(cursor: string): PaginationState {
return JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8')) as PaginationState
return decodeMicrosoftGraphTraversalCursor(cursor, 'SharePoint')
}
export const sharepointConnector: ConnectorConfig = {
@@ -765,7 +829,7 @@ export const sharepointConnector: ConnectorConfig = {
}
const documents: ExternalDocument[] = []
const maxFiles = sourceConfig.maxFiles ? Number(sourceConfig.maxFiles) : 0
const maxFiles = parseMaxFiles(sourceConfig.maxFiles)
let totalFetched = (syncContext?.totalDocsFetched as number) ?? 0
/** Set when the walk stopped for good — either the cap hit or the source ran out. */
@@ -813,9 +877,6 @@ export const sharepointConnector: ConnectorConfig = {
})
}
// Push subfolders onto the stack for depth-first traversal
state.folderStack.push(...subfolders)
// Convert files to lightweight stubs (no content download). Oversized files are
// kept as skipped stubs but do not consume the max-files cap.
const stubs = files.map((file) =>
@@ -825,7 +886,7 @@ export const sharepointConnector: ConnectorConfig = {
documents.push(...take.documents)
totalFetched += take.indexableCount
const nextLink = data['@odata.nextLink']
const nextLink = data.nextLink
if (take.capReached) {
stopPaging = true
@@ -838,10 +899,16 @@ export const sharepointConnector: ConnectorConfig = {
* or in folders still on the stack.
*/
cappedWithItemsLeft =
take.documents.length < stubs.length || Boolean(nextLink) || state.folderStack.length > 0
take.documents.length < stubs.length ||
Boolean(nextLink) ||
subfolders.length > 0 ||
state.folderStack.length > 0
break
}
/** A max-files stop must not validate folders beyond the requested scope. */
appendPendingMicrosoftGraphFolders(state.folderStack, subfolders, 'SharePoint')
if (nextLink) {
// More pages in the current folder
state.nextLink = nextLink
@@ -924,15 +991,29 @@ export const sharepointConnector: ConnectorConfig = {
throw new Error(`Failed to get SharePoint file: ${response.status}`)
}
const item = (await response.json()) as DriveItem
const item = parseDriveItemMetadata(await response.json(), externalId)
if (!item.file || !isIndexableConnectorFile(item.name)) {
return null
return {
...markSkipped(
itemToStub(item, siteName ?? siteUrl),
'File is no longer an indexable document'
),
skippedExistingDisposition: 'replace',
}
}
try {
const payload = await fetchFilePayload(accessToken, driveId, item.id, item.name)
if (!hasIndexablePayload(payload)) return null
if (!hasIndexablePayload(payload)) {
return {
...markSkipped(
itemToStub(item, siteName ?? siteUrl),
'Document contains no extractable text'
),
skippedExistingDisposition: 'replace',
}
}
const stub = itemToStub(item, siteName ?? siteUrl)
return { ...stub, ...payload, contentDeferred: false }
@@ -965,9 +1046,10 @@ export const sharepointConnector: ConnectorConfig = {
return { valid: false, error: 'Site URL is required' }
}
const maxFiles = sourceConfig.maxFiles as string | undefined
if (maxFiles && (Number.isNaN(Number(maxFiles)) || Number(maxFiles) <= 0)) {
return { valid: false, error: 'Max files must be a positive number' }
try {
parseMaxFiles(sourceConfig.maxFiles)
} catch (error) {
return { valid: false, error: toError(error).message }
}
try {
+41
View File
@@ -56,6 +56,11 @@ export interface ExternalDocument {
sourceUrl?: string
/** Hash of content for change detection (format varies by connector) */
contentHash: string
/**
* Connector-owned hash to persist for a skipped hydration that must be retried
* even when the source's listing metadata is unchanged.
*/
skippedRetryContentHash?: string
/** When true, content is empty and will be fetched via getDocument for new/changed docs only */
contentDeferred?: boolean
/**
@@ -65,6 +70,12 @@ export interface ExternalDocument {
* being silently dropped.
*/
skippedReason?: string
/**
* Controls what happens when a previously indexed document is intentionally
* skipped. The default retains its last-known-good content; `replace` removes
* stale indexed content and persists the skipped state as authoritative.
*/
skippedExistingDisposition?: 'replace'
/** Additional source-specific metadata */
metadata?: Record<string, unknown>
}
@@ -76,8 +87,26 @@ export interface ExternalDocumentList {
documents: ExternalDocument[]
nextCursor?: string
hasMore: boolean
/**
* Whether absence from this listing is authoritative enough for deletion
* reconciliation. Defaults to true. Offset-based or otherwise unstable
* provider pagination must set this to false.
*/
reconciliationSafe?: boolean
}
export const SYNC_SKIP_REASONS = [
'connector_unavailable',
'knowledge_base_deleted',
'connector_not_syncable',
'dispatch_superseded',
'sync_in_progress',
'sync_superseded',
'connector_deleted_during_sync',
] as const
export type SyncSkipReason = (typeof SYNC_SKIP_REASONS)[number]
/**
* Result of a sync operation.
*/
@@ -86,7 +115,19 @@ export interface SyncResult {
docsUpdated: number
docsDeleted: number
docsUnchanged: number
/** Source documents intentionally recorded without indexing, such as oversized files. */
docsSkipped: number
/** Source documents that failed during listing, hydration, or persistence. */
docsFailed: number
/** Immediate hand-off outcome; eventual child results live on document rows and child runs. */
processingDispatch: {
requested: number
accepted: number
failed: number
}
/** Expected queue, lifecycle, or lock no-op. Never derived from error text. */
skipReason?: SyncSkipReason
/** Diagnostic for an actual failed sync. */
error?: string
}
+91 -10
View File
@@ -61,12 +61,19 @@ import { s3Connector } from '@/connectors/s3/s3'
import { sentryConnector } from '@/connectors/sentry/sentry'
import { typeformConnector } from '@/connectors/typeform/typeform'
import {
appendPendingMicrosoftGraphFolders,
assertMicrosoftGraphNextLink,
ConnectorFileTooLargeError,
decodeMicrosoftGraphTraversalCursor,
encodeMicrosoftGraphTraversalCursor,
extractConnectorText,
hasIndexablePayload,
htmlToPlainText,
isIndexableConnectorFile,
isSkippedDocument,
MICROSOFT_GRAPH_MAX_CURSOR_ENCODED_BYTES,
MICROSOFT_GRAPH_MAX_ITEM_ID_BYTES,
MICROSOFT_GRAPH_MAX_PENDING_FOLDERS,
markSkipped,
pipelineParsedMimeType,
readBodyWithLimit,
@@ -1180,33 +1187,107 @@ describe('readBodyWithLimit', () => {
expect(result?.byteLength).toBe(2048)
})
it('returns null and cancels the stream once the cap is exceeded', async () => {
const onCancel = vi.fn()
it('returns null as soon as the streamed cap is exceeded', async () => {
const chunk = new Uint8Array(1024).fill(65)
// Cap is 2048; the third 1KB chunk pushes the total to 3072 and trips the cap,
// so the remaining body is never buffered into memory.
const result = await readBodyWithLimit(streamResponse([chunk, chunk, chunk], onCancel), 2048)
const onCancel = vi.fn()
const result = await readBodyWithLimit(
streamResponse([chunk, chunk, chunk, chunk], onCancel),
2048
)
expect(result).toBeNull()
expect(onCancel).toHaveBeenCalled()
expect(onCancel).toHaveBeenCalledOnce()
})
it('enforces the cap on bodyless responses via the arrayBuffer fallback', async () => {
it('does not materialize a bodyless response whose size is unknown', async () => {
const arrayBuffer = vi.fn(async () => new Uint8Array(5000).buffer)
// double-cast-allowed: minimal response stub exercising the no-stream branch
const oversized = {
const unknownSize = {
body: null,
arrayBuffer: async () => new Uint8Array(5000).buffer,
arrayBuffer,
} as unknown as Response
expect(await readBodyWithLimit(oversized, 4096)).toBeNull()
expect(await readBodyWithLimit(unknownSize, 4096)).toBeNull()
expect(arrayBuffer).not.toHaveBeenCalled()
})
it('uses a trusted content length to bound a bodyless response fallback', async () => {
// double-cast-allowed: minimal response stub exercising the no-stream branch
const within = {
body: null,
headers: new Headers({ 'content-length': '100' }),
arrayBuffer: async () => new Uint8Array(100).buffer,
} as unknown as Response
expect((await readBodyWithLimit(within, 4096))?.byteLength).toBe(100)
})
})
describe('Microsoft Graph traversal cursors', () => {
it('round-trips canonical cursors and accepts the former OneDrive JSON shape', () => {
const state = {
folderStack: ['folder-a', 'folder-b'],
currentFolder: 'folder-current',
nextLink: 'https://graph.microsoft.com/v1.0/me/drive/root/children?$skiptoken=abc',
}
expect(
decodeMicrosoftGraphTraversalCursor(
encodeMicrosoftGraphTraversalCursor(state, 'OneDrive'),
'OneDrive'
)
).toEqual(state)
expect(decodeMicrosoftGraphTraversalCursor(JSON.stringify(state), 'OneDrive')).toEqual(state)
})
it('rejects off-origin continuation URLs before they can receive a bearer token', () => {
expect(() => assertMicrosoftGraphNextLink('https://evil.example/steal')).toThrow(
/non-Microsoft Graph/
)
expect(() =>
decodeMicrosoftGraphTraversalCursor(
Buffer.from(
JSON.stringify({
folderStack: [],
nextLink: 'https://evil.example/steal',
})
).toString('base64'),
'SharePoint'
)
).toThrow(/non-Microsoft Graph/)
})
it('rejects invalid and oversized cursor members', () => {
const encode = (state: unknown) => Buffer.from(JSON.stringify(state)).toString('base64')
expect(() =>
decodeMicrosoftGraphTraversalCursor(encode({ folderStack: [42] }), 'OneDrive')
).toThrow(/must be a string/)
expect(() =>
decodeMicrosoftGraphTraversalCursor(
encode({ folderStack: ['x'.repeat(MICROSOFT_GRAPH_MAX_ITEM_ID_BYTES + 1)] }),
'OneDrive'
)
).toThrow(/size limit/)
expect(() =>
decodeMicrosoftGraphTraversalCursor(
'x'.repeat(MICROSOFT_GRAPH_MAX_CURSOR_ENCODED_BYTES + 1),
'OneDrive'
)
).toThrow(/encoded state exceeds/)
})
it('enforces the pending-folder cap before mutating traversal state', () => {
const pending = Array.from(
{ length: MICROSOFT_GRAPH_MAX_PENDING_FOLDERS },
(_, index) => `folder-${index}`
)
expect(() => appendPendingMicrosoftGraphFolders(pending, ['overflow'], 'OneDrive')).toThrow(
/Narrow the connector/
)
expect(pending).toHaveLength(MICROSOFT_GRAPH_MAX_PENDING_FOLDERS)
expect(pending).not.toContain('overflow')
})
})
describe('markSkipped', () => {
const stub: ExternalDocument = {
externalId: 'file-1',
+230 -18
View File
@@ -1,4 +1,9 @@
import { isPlainRecord } from '@sim/utils/object'
import type { SecureFetchResponse } from '@/lib/core/security/input-validation.server'
import {
isPayloadSizeLimitError,
readResponseToBufferWithLimit,
} from '@/lib/core/utils/stream-limits'
import { MAX_FILE_SIZE as KB_DOCUMENT_MAX_BYTES } from '@/lib/uploads/utils/validation'
import type { ExternalDocument } from '@/connectors/types'
@@ -14,6 +19,223 @@ import type { ExternalDocument } from '@/connectors/types'
*/
export const CONNECTOR_MAX_FILE_BYTES = KB_DOCUMENT_MAX_BYTES
/** Maximum number of Microsoft Graph folders retained between connector pages. */
export const MICROSOFT_GRAPH_MAX_PENDING_FOLDERS = 10_000
/**
* Graph IDs are opaque strings with no documented maximum. This ceiling is far
* above ordinary identifiers while making the traversal's string working set
* provably bounded even if a provider response or stored cursor is malformed.
*/
export const MICROSOFT_GRAPH_MAX_ITEM_ID_BYTES = 512
/** Maximum server-issued continuation URL accepted into a traversal cursor. */
export const MICROSOFT_GRAPH_MAX_NEXT_LINK_BYTES = 16 * 1024
/** Maximum serialized traversal state, before and after base64 encoding. */
export const MICROSOFT_GRAPH_MAX_CURSOR_JSON_BYTES = 8 * 1024 * 1024
export const MICROSOFT_GRAPH_MAX_CURSOR_ENCODED_BYTES = 12 * 1024 * 1024
/** Parses an optional connector limit where zero or omission means unlimited. */
export function parseOptionalUnlimitedSafeInteger(value: unknown, errorMessage: string): number {
if (value === undefined || value === null) return 0
if (typeof value !== 'string' && typeof value !== 'number') {
throw new Error(errorMessage)
}
const normalized = typeof value === 'string' ? value.trim() : value
if (normalized === '') return 0
if (typeof normalized === 'string' && !/^\d+$/.test(normalized)) {
throw new Error(errorMessage)
}
const parsed = Number(normalized)
if (!Number.isSafeInteger(parsed) || parsed < 0) {
throw new Error(errorMessage)
}
return parsed
}
const MICROSOFT_GRAPH_ORIGIN = 'https://graph.microsoft.com'
export interface MicrosoftGraphTraversalState {
nextLink?: string
currentFolder?: string
folderStack: string[]
}
export interface MicrosoftGraphDriveItemShape {
id: string
name: string
file?: Record<string, unknown>
folder?: Record<string, unknown>
package?: Record<string, unknown>
remoteItem?: Record<string, unknown>
}
export function isMicrosoftGraphDriveItem(value: unknown): value is MicrosoftGraphDriveItemShape {
return (
isPlainRecord(value) &&
typeof value.id === 'string' &&
value.id.length > 0 &&
typeof value.name === 'string' &&
value.name.length > 0 &&
(isPlainRecord(value.file) ||
isPlainRecord(value.folder) ||
isPlainRecord(value.package) ||
isPlainRecord(value.remoteItem))
)
}
export function parseMicrosoftGraphDriveItemList(
value: unknown,
label: string
): { value: MicrosoftGraphDriveItemShape[]; nextLink?: string } {
if (!isPlainRecord(value) || !Array.isArray(value.value)) {
throw new Error(`Microsoft Graph returned malformed ${label} list metadata`)
}
if (!value.value.every(isMicrosoftGraphDriveItem)) {
throw new Error(`Microsoft Graph returned malformed ${label} item metadata`)
}
const nextLink =
value['@odata.nextLink'] === undefined
? undefined
: assertMicrosoftGraphNextLink(value['@odata.nextLink'])
return { value: value.value, nextLink }
}
function assertBoundedGraphString(
value: unknown,
maxBytes: number,
field: string,
allowEmpty = false
): asserts value is string {
if (typeof value !== 'string' || (!allowEmpty && value.length === 0)) {
throw new Error(`Invalid Microsoft Graph traversal cursor: ${field} must be a string`)
}
if (Buffer.byteLength(value, 'utf8') > maxBytes) {
throw new Error(`Invalid Microsoft Graph traversal cursor: ${field} exceeds its size limit`)
}
}
/** Validates a Graph continuation URL before a bearer token can be sent to it. */
export function assertMicrosoftGraphNextLink(value: unknown): string {
assertBoundedGraphString(value, MICROSOFT_GRAPH_MAX_NEXT_LINK_BYTES, 'nextLink')
const url = new URL(value.trim())
if (url.origin !== MICROSOFT_GRAPH_ORIGIN) {
throw new Error('Refusing to follow a non-Microsoft Graph @odata.nextLink')
}
return url.toString()
}
function validateMicrosoftGraphTraversalState(
value: unknown,
label: string
): MicrosoftGraphTraversalState {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`Invalid ${label} pagination cursor`)
}
const candidate = value as Record<string, unknown>
if (!Array.isArray(candidate.folderStack)) {
throw new Error(`Invalid ${label} pagination cursor: folderStack must be an array`)
}
if (candidate.folderStack.length > MICROSOFT_GRAPH_MAX_PENDING_FOLDERS) {
throw new Error(
`${label} folder traversal exceeds the safe limit of ${MICROSOFT_GRAPH_MAX_PENDING_FOLDERS.toLocaleString()} pending folders. Narrow the connector to a document library or subfolder and retry.`
)
}
const folderStack = candidate.folderStack.map((folderId, index) => {
assertBoundedGraphString(folderId, MICROSOFT_GRAPH_MAX_ITEM_ID_BYTES, `folderStack[${index}]`)
return folderId
})
let currentFolder: string | undefined
if (candidate.currentFolder !== undefined) {
assertBoundedGraphString(
candidate.currentFolder,
MICROSOFT_GRAPH_MAX_ITEM_ID_BYTES,
'currentFolder'
)
currentFolder = candidate.currentFolder
}
const nextLink =
candidate.nextLink === undefined ? undefined : assertMicrosoftGraphNextLink(candidate.nextLink)
return { folderStack, currentFolder, nextLink }
}
/**
* Encodes bounded Graph traversal state. Base64 is canonical; decoding also
* accepts the former OneDrive raw-JSON cursor so in-flight syncs survive rollout.
*/
export function encodeMicrosoftGraphTraversalCursor(
state: MicrosoftGraphTraversalState,
label: string
): string {
const validated = validateMicrosoftGraphTraversalState(state, label)
const json = JSON.stringify(validated)
if (Buffer.byteLength(json, 'utf8') > MICROSOFT_GRAPH_MAX_CURSOR_JSON_BYTES) {
throw new Error(`Invalid ${label} pagination cursor: serialized state exceeds its size limit`)
}
const encoded = Buffer.from(json).toString('base64')
if (Buffer.byteLength(encoded, 'utf8') > MICROSOFT_GRAPH_MAX_CURSOR_ENCODED_BYTES) {
throw new Error(`Invalid ${label} pagination cursor: encoded state exceeds its size limit`)
}
return encoded
}
export function decodeMicrosoftGraphTraversalCursor(
cursor: string,
label: string
): MicrosoftGraphTraversalState {
if (Buffer.byteLength(cursor, 'utf8') > MICROSOFT_GRAPH_MAX_CURSOR_ENCODED_BYTES) {
throw new Error(`Invalid ${label} pagination cursor: encoded state exceeds its size limit`)
}
let json: string
try {
json = cursor.trimStart().startsWith('{')
? cursor
: Buffer.from(cursor, 'base64').toString('utf8')
} catch {
throw new Error(`Invalid ${label} pagination cursor`)
}
if (Buffer.byteLength(json, 'utf8') > MICROSOFT_GRAPH_MAX_CURSOR_JSON_BYTES) {
throw new Error(`Invalid ${label} pagination cursor: serialized state exceeds its size limit`)
}
let parsed: unknown
try {
parsed = JSON.parse(json)
} catch {
throw new Error(`Invalid ${label} pagination cursor`)
}
return validateMicrosoftGraphTraversalState(parsed, label)
}
export function appendPendingMicrosoftGraphFolders(
pendingFolders: string[],
discoveredFolders: string[],
label: string
): void {
if (pendingFolders.length + discoveredFolders.length > MICROSOFT_GRAPH_MAX_PENDING_FOLDERS) {
throw new Error(
`${label} folder traversal exceeds the safe limit of ${MICROSOFT_GRAPH_MAX_PENDING_FOLDERS.toLocaleString()} pending folders. Narrow the connector to a document library or subfolder and retry.`
)
}
for (let index = 0; index < discoveredFolders.length; index++) {
assertBoundedGraphString(
discoveredFolders[index],
MICROSOFT_GRAPH_MAX_ITEM_ID_BYTES,
`discoveredFolders[${index}]`
)
}
pendingFolders.push(...discoveredFolders)
}
/** The named entities connector markup actually carries, decoded to their character. */
const NAMED_ENTITIES: Record<string, string> = {
amp: '&',
@@ -369,25 +591,15 @@ export async function readBodyWithLimit(
response: Response | SecureFetchResponse,
maxBytes: number
): Promise<Buffer | null> {
if (!response.body) {
const buffer = Buffer.from(await response.arrayBuffer())
return buffer.byteLength > maxBytes ? null : buffer
try {
return await readResponseToBufferWithLimit(response, {
maxBytes,
label: 'Connector file download',
})
} catch (error) {
if (isPayloadSizeLimitError(error)) return null
throw error
}
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let total = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
total += value.byteLength
if (total > maxBytes) {
await reader.cancel().catch(() => {})
return null
}
chunks.push(value)
}
return Buffer.concat(chunks)
}
/**
@@ -94,6 +94,7 @@ export const syncLogDataSchema = z
docsUpdated: z.number(),
docsDeleted: z.number(),
docsUnchanged: z.number(),
docsSkipped: z.number().int().nonnegative().default(0),
docsFailed: z.number(),
errorMessage: z.string().nullable(),
})
@@ -1595,6 +1595,12 @@ export const v2KnowledgeConnectorSyncLogSchema = z
docsUpdated: z.number().int().nonnegative().describe('Documents updated.'),
docsDeleted: z.number().int().nonnegative().describe('Documents deleted.'),
docsUnchanged: z.number().int().nonnegative().describe('Documents unchanged.'),
docsSkipped: z
.number()
.int()
.nonnegative()
.default(0)
.describe('Documents intentionally skipped because they could not be indexed safely.'),
docsFailed: z.number().int().nonnegative().describe('Documents that failed to synchronize.'),
errorMessage: z.string().nullable().describe('Synchronization error, or null.'),
})
+2 -4
View File
@@ -19,8 +19,8 @@ function options(over: Record<string, unknown> = {}) {
>[0]
}
/** Tiny delays so retry cases do not spend real seconds sleeping. */
const FAST = { initialDelayMs: 1, maxDelayMs: 1 }
/** Tiny waits keep retry cases fast; the explicit budget absorbs test-runner scheduling latency. */
const FAST = { initialDelayMs: 1, maxDelayMs: 1, retryBudgetMs: 1_000 }
function sites(entries: Array<{ id: string; url: string }>) {
return createMockResponse({ json: entries })
@@ -108,8 +108,6 @@ describe('resolveAtlassianCloudId', () => {
it('gives up on a persistent fault within a bounded attempt budget', async () => {
fetchMock.mockImplementation(async () => failure(500))
// Delays only. `maxRetries` still comes from the discovery budget, so this
// fails if the shared default of 5 ever leaks back in.
await expect(resolveAtlassianCloudId(options({ retryOptions: FAST }))).rejects.toThrow(
/Failed to fetch Jira accessible resources: 500/
)
+31
View File
@@ -0,0 +1,31 @@
/** Raised before a chunker would produce more than its configured output ceiling. */
export class ChunkLimitExceededError extends Error {
readonly maxChunks: number
constructor(maxChunks: number) {
super(`Chunk production exceeded the configured limit of ${maxChunks.toLocaleString()}`)
this.name = 'ChunkLimitExceededError'
this.maxChunks = maxChunks
}
}
/** Shared output budget that lets recursive chunkers enforce one aggregate ceiling. */
export class ChunkBudget {
private produced = 0
private readonly maxChunks?: number
constructor(maxChunks?: number) {
if (maxChunks !== undefined && (!Number.isSafeInteger(maxChunks) || maxChunks < 0)) {
throw new RangeError('maxChunks must be a non-negative safe integer')
}
this.maxChunks = maxChunks
}
add<T>(target: T[], value: T): void {
if (this.maxChunks !== undefined && this.produced >= this.maxChunks) {
throw new ChunkLimitExceededError(this.maxChunks)
}
target.push(value)
this.produced++
}
}
+114
View File
@@ -0,0 +1,114 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { ChunkLimitExceededError } from '@/lib/chunkers/chunk-budget'
import { JsonYamlChunker } from '@/lib/chunkers/json-yaml-chunker'
import { RecursiveChunker } from '@/lib/chunkers/recursive-chunker'
import { RegexChunker } from '@/lib/chunkers/regex-chunker'
import { SentenceChunker } from '@/lib/chunkers/sentence-chunker'
import { StructuredDataChunker } from '@/lib/chunkers/structured-data-chunker'
import { TextChunker } from '@/lib/chunkers/text-chunker'
import { TokenChunker } from '@/lib/chunkers/token-chunker'
const LIMIT_CASES = [
{
name: 'text',
run: () =>
new TextChunker({ chunkSize: 1, maxChunks: 2 }).chunk(
'alpha bravo charlie delta echo foxtrot'
),
},
{
name: 'token',
run: () =>
new TokenChunker({ chunkSize: 1, maxChunks: 2 }).chunk(
'alpha bravo charlie delta echo foxtrot'
),
},
{
name: 'sentence',
run: () =>
new SentenceChunker({ chunkSize: 2, maxChunks: 2 }).chunk(
'Alpha bravo. Charlie delta. Echo foxtrot. Golf hotel.'
),
},
{
name: 'recursive',
run: () =>
new RecursiveChunker({ chunkSize: 1, maxChunks: 2, separators: ['|'] }).chunk(
'alpha|bravo|charlie|delta'
),
},
{
name: 'regex',
run: () =>
new RegexChunker({
pattern: '---',
strictBoundaries: true,
chunkSize: 100,
maxChunks: 2,
}).chunk('alpha---bravo---charlie---delta'),
},
{
name: 'structured data',
run: () =>
StructuredDataChunker.chunkStructuredData(
[
'name,value',
...Array.from({ length: 16 }, (_, index) => `row-${index},value-${index}`),
].join('\n'),
{ chunkSize: 1, maxChunks: 2 }
),
},
{
name: 'JSON/YAML',
run: () =>
new JsonYamlChunker({ chunkSize: 1, maxChunks: 2 }).chunk(
JSON.stringify(['alpha', 'bravo', 'charlie', 'delta'])
),
},
] as const
describe('chunk production ceiling', () => {
it.each(LIMIT_CASES)('$name chunker stops before producing a third chunk', async ({ run }) => {
await expect(run()).rejects.toBeInstanceOf(ChunkLimitExceededError)
})
it('does not retain budget usage across calls on one chunker instance', async () => {
const chunker = new TokenChunker({ chunkSize: 100, maxChunks: 1 })
await expect(chunker.chunk('first document')).resolves.toHaveLength(1)
await expect(chunker.chunk('second document')).resolves.toHaveLength(1)
})
it('admits the exact limit when a short trailing token chunk is filtered out', async () => {
const chunker = new TokenChunker({
chunkSize: 1,
minCharactersPerChunk: 4,
maxChunks: 2,
})
await expect(chunker.chunk('aaaa aaaa x')).resolves.toHaveLength(2)
})
it('stops separator-heavy text production at the configured ceiling', async () => {
const content = Array.from({ length: 50_000 }, () => 'x').join(' ')
const chunker = new TextChunker({ chunkSize: 1, maxChunks: 2 })
await expect(chunker.chunk(content)).rejects.toBeInstanceOf(ChunkLimitExceededError)
})
it('stops strict-regex splitting without materializing every remaining segment', async () => {
const content = Array.from({ length: 50_000 }, () => 'x').join('---')
const chunker = new RegexChunker({
pattern: '---',
strictBoundaries: true,
chunkSize: 100,
maxChunks: 2,
})
await expect(chunker.chunk(content)).rejects.toBeInstanceOf(ChunkLimitExceededError)
})
})
@@ -8,6 +8,7 @@ vi.mock('@/lib/knowledge/embeddings', () => ({
getConfiguredEmbeddingModel: vi.fn(() => 'test-model'),
}))
import { ChunkLimitExceededError } from '@/lib/chunkers/chunk-budget'
import { DocsChunker } from '@/lib/chunkers/docs-chunker'
function cleanContent(content: string): string {
@@ -129,3 +130,42 @@ describe('cleanContent scaffolding strips', () => {
expect(cleaned).toContain('Inside text stays')
})
})
describe('DocsChunker output budget', () => {
function splitContent(
chunker: DocsChunker,
content: string
): Promise<{ chunks: string[]; cleanedContent: string }> {
return (
chunker as unknown as {
splitContent(content: string): Promise<{ chunks: string[]; cleanedContent: string }>
}
).splitContent.call(chunker, content)
}
it('applies maxChunks after short intermediate chunks are filtered out', async () => {
const chunker = new DocsChunker({ chunkSize: 1, chunkOverlap: 0, maxChunks: 1 })
await expect(splitContent(chunker, 'a b c d')).resolves.toEqual({
chunks: [],
cleanedContent: 'a b c d',
})
})
it('rejects when the final transformed output exceeds maxChunks', async () => {
const chunker = new DocsChunker({ chunkSize: 30, chunkOverlap: 0, maxChunks: 1 })
const content = `${'a'.repeat(110)}\n\n${'b'.repeat(110)}`
await expect(splitContent(chunker, content)).rejects.toThrow(ChunkLimitExceededError)
})
it('enforces maxChunks after oversized chunks are split into final chunks', () => {
const chunker = new DocsChunker({ chunkSize: 30, maxChunks: 1 })
const enforceSizeLimit = (
chunker as unknown as { enforceSizeLimit(chunks: string[]): string[] }
).enforceSizeLimit.bind(chunker)
const longLine = 'a'.repeat(120)
expect(() => enforceSizeLimit([`${longLine}\n${longLine}`])).toThrow(ChunkLimitExceededError)
})
})
+12 -4
View File
@@ -1,6 +1,7 @@
import fs from 'fs/promises'
import path from 'path'
import { createLogger } from '@sim/logger'
import { ChunkBudget } from '@/lib/chunkers/chunk-budget'
import { TextChunker } from '@/lib/chunkers/text-chunker'
import type { DocChunk, DocsChunkerOptions } from '@/lib/chunkers/types'
import { estimateTokens } from '@/lib/chunkers/utils'
@@ -64,9 +65,11 @@ export class DocsChunker {
private readonly textChunker: TextChunker
private readonly baseUrl: string
private readonly chunkSize: number
private readonly maxChunks?: number
constructor(options: DocsChunkerOptions = {}) {
this.chunkSize = options.chunkSize ?? 300
this.maxChunks = options.maxChunks
this.textChunker = new TextChunker({
chunkSize: this.chunkSize,
minCharactersPerChunk: options.minCharactersPerChunk ?? 1,
@@ -395,12 +398,17 @@ export class DocsChunker {
private enforceSizeLimit(chunks: string[]): string[] {
const finalChunks: string[] = []
const budget = new ChunkBudget(this.maxChunks)
const addFinalChunk = (chunk: string): void => {
const normalized = chunk.trim()
if (normalized.length > 100) budget.add(finalChunks, normalized)
}
for (const chunk of chunks) {
const tokens = estimateTokens(chunk)
if (tokens <= this.chunkSize) {
finalChunks.push(chunk)
addFinalChunk(chunk)
} else {
const lines = chunk.split('\n')
let currentChunk = ''
@@ -412,18 +420,18 @@ export class DocsChunker {
currentChunk = testChunk
} else {
if (currentChunk.trim()) {
finalChunks.push(currentChunk.trim())
addFinalChunk(currentChunk)
}
currentChunk = line
}
}
if (currentChunk.trim()) {
finalChunks.push(currentChunk.trim())
addFinalChunk(currentChunk)
}
}
}
return finalChunks.filter((chunk) => chunk.trim().length > 100)
return finalChunks
}
}
+1
View File
@@ -1,3 +1,4 @@
export { ChunkBudget, ChunkLimitExceededError } from './chunk-budget'
export { DocsChunker } from './docs-chunker'
export { JsonYamlChunker } from './json-yaml-chunker'
export { RecursiveChunker } from './recursive-chunker'
@@ -14,6 +14,29 @@ vi.mock('@/lib/tokenization/estimators', () => ({
}))
describe('JsonYamlChunker', () => {
it.each([
0,
-1,
0.5,
Number.NaN,
Number.NEGATIVE_INFINITY,
Number.POSITIVE_INFINITY,
Number.MAX_SAFE_INTEGER,
])('rejects an invalid chunk size at construction: %s', (chunkSize) => {
expect(() => new JsonYamlChunker({ chunkSize })).toThrow(
'JSON/YAML chunk size must be a finite number between 1'
)
})
it('normalizes a fractional legacy chunk size to its integer token ceiling', async () => {
const chunks = await new JsonYamlChunker({ chunkSize: 100.5, minCharactersPerChunk: 1 }).chunk(
JSON.stringify({ value: 'x'.repeat(1_000) })
)
expect(chunks.length).toBeGreaterThan(1)
expect(chunks.every((chunk) => chunk.tokenCount <= 100)).toBe(true)
})
describe('isStructuredData', () => {
it('should detect valid JSON', () => {
expect(JsonYamlChunker.isStructuredData('{"key": "value"}')).toBe(true)
@@ -269,6 +292,17 @@ server:
expect(chunks.length).toBeGreaterThan(0)
})
it('should fall back to bounded text chunking when YAML traversal fails', async () => {
const chunker = new JsonYamlChunker({ chunkSize: 1000, minCharactersPerChunk: 1 })
const cyclicYaml = ['root: &root', ' value: readable', ' self: *root'].join('\n')
const chunks = await chunker.chunk(cyclicYaml)
expect(chunks).toHaveLength(1)
expect(chunks[0].text).toContain('&root')
expect(chunks[0].text).toContain('readable')
})
})
describe('large inputs', () => {
@@ -293,6 +327,32 @@ server:
const chunks = await chunker.chunk(json)
expect(chunks.length).toBeGreaterThan(1)
expect(chunks.every((chunk) => chunk.tokenCount <= 100)).toBe(true)
})
it('splits a long single-line scalar to the configured chunk size', async () => {
const chunker = new JsonYamlChunker({ chunkSize: 1024, minCharactersPerChunk: 1 })
const json = JSON.stringify({ value: `START-${'x'.repeat(32_755)}-END` })
const chunks = await chunker.chunk(json)
expect(chunks.length).toBeGreaterThan(1)
expect(chunks.every((chunk) => chunk.tokenCount <= 1024)).toBe(true)
expect(chunks.some((chunk) => chunk.text.includes('START-'))).toBe(true)
expect(chunks.some((chunk) => chunk.text.includes('-END'))).toBe(true)
})
it('splits a long scalar nested beyond the structured traversal depth', async () => {
const chunker = new JsonYamlChunker({ chunkSize: 1024, minCharactersPerChunk: 1 })
let nested: unknown = `START-${'x'.repeat(40_000)}-END`
for (let depth = 0; depth < 6; depth++) nested = [nested]
const chunks = await chunker.chunk(JSON.stringify(nested))
expect(chunks.length).toBeGreaterThan(1)
expect(chunks.every((chunk) => chunk.tokenCount <= 1024)).toBe(true)
expect(chunks.some((chunk) => chunk.text.includes('START-'))).toBe(true)
expect(chunks.some((chunk) => chunk.text.includes('-END'))).toBe(true)
})
it.concurrent('should handle deeply nested structure up to depth limit', async () => {
@@ -331,6 +391,82 @@ server:
})
describe('chunk metadata', () => {
it('preserves every source character and offset when bounding oversized chunks', async () => {
const key = 'p'.repeat(80)
const value = { value: 'alpha beta gamma' }
const expectedText = `// ${key}\n${JSON.stringify(value, null, 2)}`
const chunker = new JsonYamlChunker({ chunkSize: 10, minCharactersPerChunk: 1 })
const chunks = await chunker.chunk(JSON.stringify({ [key]: value }))
expect(chunks.length).toBeGreaterThan(1)
for (const chunk of chunks) {
expect(expectedText.slice(chunk.metadata.startIndex, chunk.metadata.endIndex)).toBe(
chunk.text
)
}
expect(chunks.map((chunk) => chunk.text).join('')).toBe(expectedText)
})
it('preserves spaces inside an oversized scalar line', async () => {
const content = JSON.stringify('AAAAAA BBBBBB')
const chunker = new JsonYamlChunker({ chunkSize: 2, minCharactersPerChunk: 1 })
const chunks = await chunker.chunk(content)
expect(chunks.length).toBeGreaterThan(1)
expect(chunks.every((chunk) => chunk.tokenCount <= 2)).toBe(true)
expect(chunks.map((chunk) => chunk.text).join('')).toBe(content)
for (const chunk of chunks) {
expect(content.slice(chunk.metadata.startIndex, chunk.metadata.endIndex)).toBe(chunk.text)
}
})
it('preserves array item ranges when batch formatting requires bounded splits', async () => {
const values = ['x0', 'x1', 'x2', 'x3']
const chunker = new JsonYamlChunker({ chunkSize: 2, minCharactersPerChunk: 1 })
const chunks = await chunker.chunk(JSON.stringify(values))
expect(new Set(chunks.map((chunk) => JSON.stringify(chunk.metadata)))).toEqual(
new Set([
JSON.stringify({ startIndex: 0, endIndex: 1 }),
JSON.stringify({ startIndex: 2, endIndex: 3 }),
])
)
for (const chunk of chunks) {
expect(chunk.tokenCount).toBeLessThanOrEqual(2)
}
expect(
chunks
.filter((chunk) => chunk.metadata.startIndex === 0)
.map((chunk) => chunk.text)
.join(' ')
).toContain('x0')
expect(
chunks
.filter((chunk) => chunk.metadata.startIndex === 2)
.map((chunk) => chunk.text)
.join(' ')
).toContain('x2')
})
it('preserves the item range when an oversized array item is split', async () => {
const oversizedItem = 'alpha beta gamma delta epsilon zeta eta theta'
const chunker = new JsonYamlChunker({ chunkSize: 5, minCharactersPerChunk: 1 })
const chunks = await chunker.chunk(JSON.stringify([oversizedItem, 'short']))
expect(chunks.filter((chunk) => chunk.metadata.startIndex === 0).length).toBeGreaterThan(1)
expect(chunks.every((chunk) => chunk.tokenCount <= 5)).toBe(true)
expect(new Set(chunks.map((chunk) => JSON.stringify(chunk.metadata)))).toEqual(
new Set([
JSON.stringify({ startIndex: 0, endIndex: 0 }),
JSON.stringify({ startIndex: 1, endIndex: 1 }),
])
)
})
it.concurrent('should include startIndex and endIndex in metadata', async () => {
const chunker = new JsonYamlChunker({ chunkSize: 100 })
const json = JSON.stringify({ key: 'value' })
+151 -59
View File
@@ -1,7 +1,14 @@
import { createLogger } from '@sim/logger'
import * as yaml from 'js-yaml'
import { ChunkBudget, ChunkLimitExceededError } from '@/lib/chunkers/chunk-budget'
import type { Chunk, ChunkerOptions } from '@/lib/chunkers/types'
import { estimateTokens } from '@/lib/chunkers/utils'
import {
estimateTokens,
iterateLines,
iterateLosslessWordBoundaryChunkSpans,
normalizeTokenChunkSize,
tokensToChars,
} from '@/lib/chunkers/utils'
const logger = createLogger('JsonYamlChunker')
@@ -9,16 +16,19 @@ type JsonPrimitive = string | number | boolean | null
type JsonValue = JsonPrimitive | JsonObject | JsonArray
type JsonObject = { [key: string]: JsonValue }
type JsonArray = JsonValue[]
type BoundedChunkMetadataMode = 'text-offsets' | 'preserve-range'
const MAX_DEPTH = 5
export class JsonYamlChunker {
private chunkSize: number
private minCharactersPerChunk: number
private maxChunks?: number
constructor(options: ChunkerOptions = {}) {
this.chunkSize = options.chunkSize ?? 1024
this.chunkSize = normalizeTokenChunkSize(options.chunkSize ?? 1024, 'JSON/YAML chunk size')
this.minCharactersPerChunk = options.minCharactersPerChunk ?? 100
this.maxChunks = options.maxChunks
}
static isStructuredData(content: string): boolean {
@@ -43,25 +53,36 @@ export class JsonYamlChunker {
} catch {
data = yaml.load(content) as JsonValue
}
const chunks = this.chunkStructuredData(data, [], 0)
const chunks: Chunk[] = []
this.chunkStructuredData(data, [], 0, chunks, new ChunkBudget(this.maxChunks))
const totalTokens = chunks.reduce((sum, c) => sum + c.tokenCount, 0)
logger.info(`JSON chunking complete: ${chunks.length} chunks, ${totalTokens} total tokens`)
return chunks
} catch (error) {
logger.info('JSON parsing failed, falling back to text chunking')
return this.chunkAsText(content)
if (error instanceof ChunkLimitExceededError) throw error
logger.info('Structured data chunking failed, falling back to text chunking')
return this.chunkAsText(content, new ChunkBudget(this.maxChunks))
}
}
private chunkStructuredData(data: JsonValue, path: string[], depth: number): Chunk[] {
private chunkStructuredData(
data: JsonValue,
path: string[],
depth: number,
chunks: Chunk[],
budget: ChunkBudget
): void {
if (Array.isArray(data)) {
return this.chunkArray(data, path, depth)
this.chunkArray(data, path, depth, chunks, budget)
return
}
if (typeof data === 'object' && data !== null) {
return this.chunkObject(data as JsonObject, path, depth)
this.chunkObject(data as JsonObject, path, depth, chunks, budget)
return
}
const content = JSON.stringify(data, null, 2)
@@ -69,25 +90,29 @@ export class JsonYamlChunker {
const contentTokens = estimateTokens(content)
if (contentTokens > this.chunkSize) {
return this.chunkAsText(contextHeader + content)
this.chunkAsText(contextHeader + content, budget, chunks)
return
}
if (content.length < this.minCharactersPerChunk) {
return []
return
}
const text = contextHeader + content
return [
{
text,
tokenCount: estimateTokens(text),
metadata: { startIndex: 0, endIndex: text.length },
},
]
this.addBoundedChunk(chunks, budget, {
text,
tokenCount: estimateTokens(text),
metadata: { startIndex: 0, endIndex: text.length },
})
}
private chunkArray(arr: JsonArray, path: string[], depth: number): Chunk[] {
const chunks: Chunk[] = []
private chunkArray(
arr: JsonArray,
path: string[],
depth: number,
chunks: Chunk[],
budget: ChunkBudget
): void {
let currentBatch: JsonValue[] = []
let currentTokens = 0
@@ -100,25 +125,37 @@ export class JsonYamlChunker {
if (itemTokens > this.chunkSize) {
if (currentBatch.length > 0) {
chunks.push(
this.buildBatchChunk(contextHeader, currentBatch, i - currentBatch.length, i - 1)
this.addBoundedChunk(
chunks,
budget,
this.buildBatchChunk(contextHeader, currentBatch, i - currentBatch.length, i - 1),
'preserve-range'
)
currentBatch = []
currentTokens = 0
}
if (depth < MAX_DEPTH && typeof item === 'object' && item !== null) {
chunks.push(...this.chunkStructuredData(item, [...path, `[${i}]`], depth + 1))
this.chunkStructuredData(item, [...path, `[${i}]`], depth + 1, chunks, budget)
} else {
chunks.push({
text: contextHeader + itemStr,
tokenCount: itemTokens,
metadata: { startIndex: i, endIndex: i },
})
const text = contextHeader + itemStr
this.addBoundedChunk(
chunks,
budget,
{
text,
tokenCount: estimateTokens(text),
metadata: { startIndex: i, endIndex: i },
},
'preserve-range'
)
}
} else if (currentTokens + itemTokens > this.chunkSize && currentBatch.length > 0) {
chunks.push(
this.buildBatchChunk(contextHeader, currentBatch, i - currentBatch.length, i - 1)
this.addBoundedChunk(
chunks,
budget,
this.buildBatchChunk(contextHeader, currentBatch, i - currentBatch.length, i - 1),
'preserve-range'
)
currentBatch = [item]
currentTokens = itemTokens
@@ -129,21 +166,27 @@ export class JsonYamlChunker {
}
if (currentBatch.length > 0) {
chunks.push(
this.addBoundedChunk(
chunks,
budget,
this.buildBatchChunk(
contextHeader,
currentBatch,
arr.length - currentBatch.length,
arr.length - 1
)
),
'preserve-range'
)
}
return chunks
}
private chunkObject(obj: JsonObject, path: string[], depth: number): Chunk[] {
const chunks: Chunk[] = []
private chunkObject(
obj: JsonObject,
path: string[],
depth: number,
chunks: Chunk[],
budget: ChunkBudget
): void {
const entries = Object.entries(obj)
const fullContent = JSON.stringify(obj, null, 2)
@@ -152,13 +195,12 @@ export class JsonYamlChunker {
if (fullTokens <= this.chunkSize) {
const contextHeader = path.length > 0 ? `// ${path.join('.')}\n` : ''
const text = contextHeader + fullContent
return [
{
text,
tokenCount: estimateTokens(text),
metadata: { startIndex: 0, endIndex: text.length },
},
]
this.addBoundedChunk(chunks, budget, {
text,
tokenCount: estimateTokens(text),
metadata: { startIndex: 0, endIndex: text.length },
})
return
}
const contextHeader = path.length > 0 ? `// ${path.join('.')}\n` : ''
@@ -172,7 +214,7 @@ export class JsonYamlChunker {
if (valueTokens > this.chunkSize) {
if (Object.keys(currentObj).length > 0) {
const objContent = contextHeader + JSON.stringify(currentObj, null, 2)
chunks.push({
this.addBoundedChunk(chunks, budget, {
text: objContent,
tokenCount: estimateTokens(objContent),
metadata: { startIndex: 0, endIndex: objContent.length },
@@ -182,20 +224,16 @@ export class JsonYamlChunker {
}
if (depth < MAX_DEPTH && typeof value === 'object' && value !== null) {
chunks.push(...this.chunkStructuredData(value, [...path, key], depth + 1))
this.chunkStructuredData(value, [...path, key], depth + 1, chunks, budget)
} else {
chunks.push({
text: contextHeader + valueStr,
tokenCount: valueTokens,
metadata: { startIndex: 0, endIndex: valueStr.length },
})
this.chunkAsText(contextHeader + valueStr, budget, chunks)
}
} else if (
currentTokens + valueTokens > this.chunkSize &&
Object.keys(currentObj).length > 0
) {
const objContent = contextHeader + JSON.stringify(currentObj, null, 2)
chunks.push({
this.addBoundedChunk(chunks, budget, {
text: objContent,
tokenCount: estimateTokens(objContent),
metadata: { startIndex: 0, endIndex: objContent.length },
@@ -210,14 +248,12 @@ export class JsonYamlChunker {
if (Object.keys(currentObj).length > 0) {
const objContent = contextHeader + JSON.stringify(currentObj, null, 2)
chunks.push({
this.addBoundedChunk(chunks, budget, {
text: objContent,
tokenCount: estimateTokens(objContent),
metadata: { startIndex: 0, endIndex: objContent.length },
})
}
return chunks
}
private buildBatchChunk(
@@ -234,18 +270,74 @@ export class JsonYamlChunker {
}
}
private chunkAsText(content: string): Chunk[] {
const chunks: Chunk[] = []
const lines = content.split('\n')
private addBoundedChunk(
chunks: Chunk[],
budget: ChunkBudget,
chunk: Chunk,
metadataMode: BoundedChunkMetadataMode = 'text-offsets'
): void {
if (chunk.tokenCount <= this.chunkSize) {
budget.add(chunks, chunk)
return
}
for (const segment of iterateLosslessWordBoundaryChunkSpans(
chunk.text,
tokensToChars(this.chunkSize)
)) {
budget.add(chunks, {
text: segment.text,
tokenCount: estimateTokens(segment.text),
metadata:
metadataMode === 'preserve-range'
? chunk.metadata
: {
startIndex: chunk.metadata.startIndex + segment.startIndex,
endIndex: chunk.metadata.startIndex + segment.endIndex,
},
})
}
}
private chunkAsText(content: string, budget: ChunkBudget, chunks: Chunk[] = []): Chunk[] {
let currentChunk = ''
let currentTokens = 0
let startIndex = 0
for (const line of lines) {
for (const line of iterateLines(content)) {
const lineTokens = estimateTokens(line)
if (lineTokens > this.chunkSize) {
if (currentChunk) {
budget.add(chunks, {
text: currentChunk,
tokenCount: currentTokens,
metadata: { startIndex, endIndex: startIndex + currentChunk.length },
})
startIndex += currentChunk.length + 1
currentChunk = ''
currentTokens = 0
}
const lineStartIndex = startIndex
for (const segment of iterateLosslessWordBoundaryChunkSpans(
line,
tokensToChars(this.chunkSize)
)) {
budget.add(chunks, {
text: segment.text,
tokenCount: estimateTokens(segment.text),
metadata: {
startIndex: lineStartIndex + segment.startIndex,
endIndex: lineStartIndex + segment.endIndex,
},
})
}
startIndex += line.length + 1
continue
}
if (currentTokens + lineTokens > this.chunkSize && currentChunk) {
chunks.push({
budget.add(chunks, {
text: currentChunk,
tokenCount: currentTokens,
metadata: { startIndex, endIndex: startIndex + currentChunk.length },
@@ -261,7 +353,7 @@ export class JsonYamlChunker {
}
if (currentChunk && currentChunk.length >= this.minCharactersPerChunk) {
chunks.push({
budget.add(chunks, {
text: currentChunk,
tokenCount: currentTokens,
metadata: { startIndex, endIndex: startIndex + currentChunk.length },
+26 -19
View File
@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
import { ChunkBudget } from '@/lib/chunkers/chunk-budget'
import { MAX_CHUNKING_SEPARATOR_LENGTH, MAX_CHUNKING_SEPARATORS } from '@/lib/chunkers/constants'
import type { Chunk, RecursiveChunkerOptions } from '@/lib/chunkers/types'
import {
@@ -6,8 +7,10 @@ import {
buildChunks,
cleanText,
estimateTokens,
hasMultipleNonEmptyLiteralParts,
iterateLiteralParts,
iterateWordBoundaryChunks,
resolveChunkerOptions,
splitAtWordBoundaries,
tokensToChars,
} from '@/lib/chunkers/utils'
@@ -57,11 +60,13 @@ export class RecursiveChunker {
private readonly chunkSize: number
private readonly chunkOverlap: number
private readonly separators: string[]
private readonly maxChunks?: number
constructor(options: RecursiveChunkerOptions = {}) {
const resolved = resolveChunkerOptions(options)
this.chunkSize = resolved.chunkSize
this.chunkOverlap = resolved.chunkOverlap
this.maxChunks = options.maxChunks
/**
* Bounded here as well as at the API boundary: a config persisted before the
@@ -92,11 +97,17 @@ export class RecursiveChunker {
}
}
private splitRecursively(text: string, separatorIndex = 0): string[] {
private splitRecursively(
text: string,
chunks: string[],
budget: ChunkBudget,
separatorIndex = 0
): void {
const tokenCount = estimateTokens(text)
if (tokenCount <= this.chunkSize) {
return text.trim() ? [text] : []
if (text.trim()) budget.add(chunks, text)
return
}
/**
@@ -105,7 +116,6 @@ export class RecursiveChunker {
*/
let index = separatorIndex
let separator = ''
let parts: string[] = []
while (index < this.separators.length) {
separator = this.separators[index]
@@ -115,9 +125,7 @@ export class RecursiveChunker {
break
}
parts = text.split(separator).filter((part) => part.trim())
if (parts.length > 1) {
if (hasMultipleNonEmptyLiteralParts(text, separator)) {
break
}
@@ -126,27 +134,27 @@ export class RecursiveChunker {
if (index >= this.separators.length) {
const chunkSizeChars = tokensToChars(this.chunkSize)
return splitAtWordBoundaries(text, chunkSizeChars)
for (const part of iterateWordBoundaryChunks(text, chunkSizeChars)) {
budget.add(chunks, part)
}
return
}
const chunks: string[] = []
let currentChunk = ''
for (const part of parts) {
for (const part of iterateLiteralParts(text, separator)) {
if (!part.trim()) continue
const testChunk = currentChunk + (currentChunk ? separator : '') + part
if (estimateTokens(testChunk) <= this.chunkSize) {
currentChunk = testChunk
} else {
if (currentChunk.trim()) {
chunks.push(currentChunk.trim())
budget.add(chunks, currentChunk.trim())
}
if (estimateTokens(part) > this.chunkSize) {
const subChunks = this.splitRecursively(part, index + 1)
for (const subChunk of subChunks) {
chunks.push(subChunk)
}
this.splitRecursively(part, chunks, budget, index + 1)
currentChunk = ''
} else {
currentChunk = part
@@ -155,10 +163,8 @@ export class RecursiveChunker {
}
if (currentChunk.trim()) {
chunks.push(currentChunk.trim())
budget.add(chunks, currentChunk.trim())
}
return chunks
}
async chunk(content: string): Promise<Chunk[]> {
@@ -167,7 +173,8 @@ export class RecursiveChunker {
}
const cleaned = cleanText(content)
let chunks = this.splitRecursively(cleaned)
let chunks: string[] = []
this.splitRecursively(cleaned, chunks, new ChunkBudget(this.maxChunks))
if (this.chunkOverlap > 0) {
const overlapChars = tokensToChars(this.chunkOverlap)
@@ -256,6 +256,35 @@ describe('RegexChunker', () => {
})
describe('strictBoundaries mode', () => {
it.concurrent('preserves delimiter-only content as one chunk', async () => {
const chunker = new RegexChunker({
pattern: '---',
chunkSize: 1024,
strictBoundaries: true,
})
const chunks = await chunker.chunk('------')
expect(chunks).toHaveLength(1)
expect(chunks[0].text).toBe('------')
})
it.concurrent(
'preserves the original text when only one meaningful segment remains',
async () => {
const chunker = new RegexChunker({
pattern: '---',
chunkSize: 1024,
strictBoundaries: true,
})
const chunks = await chunker.chunk('---alpha---')
expect(chunks).toHaveLength(1)
expect(chunks[0].text).toBe('---alpha---')
}
)
it.concurrent(
'should produce one chunk per match without merging small adjacent segments',
async () => {
+59 -26
View File
@@ -1,13 +1,14 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { ChunkBudget } from '@/lib/chunkers/chunk-budget'
import type { Chunk, RegexChunkerOptions } from '@/lib/chunkers/types'
import {
addOverlap,
buildChunks,
cleanText,
estimateTokens,
iterateWordBoundaryChunks,
resolveChunkerOptions,
splitAtWordBoundaries,
tokensToChars,
} from '@/lib/chunkers/utils'
import {
@@ -63,6 +64,7 @@ export class RegexChunker {
private readonly chunkOverlap: number
private readonly regex: LinearRegex
private readonly strictBoundaries: boolean
private readonly maxChunks?: number
constructor(options: RegexChunkerOptions) {
const resolved = resolveChunkerOptions(options)
@@ -70,6 +72,7 @@ export class RegexChunker {
this.chunkOverlap = resolved.chunkOverlap
this.regex = this.compilePattern(options.pattern)
this.strictBoundaries = options.strictBoundaries ?? false
this.maxChunks = options.maxChunks
}
/**
@@ -122,21 +125,41 @@ export class RegexChunker {
if (!this.strictBoundaries && estimateTokens(cleaned) <= this.chunkSize) {
logger.info('Content fits in single chunk')
return buildChunks([cleaned], 0)
const texts: string[] = []
new ChunkBudget(this.maxChunks).add(texts, cleaned)
return buildChunks(texts, 0)
}
const segments = this.regex.split(cleaned).filter((s) => s.trim().length > 0)
if (segments.length <= 1) {
if (this.strictBoundaries) {
const segments = this.nonEmptySegments(cleaned)
if (this.strictBoundaries) {
const first = segments.next()
const second = segments.next()
if (first.done || second.done) {
const chunks: string[] = []
new ChunkBudget(this.maxChunks).add(chunks, cleaned.trim())
logger.info('Regex pattern produced no splits in strict mode, returning single chunk')
return buildChunks([cleaned.trim()], 0)
return buildChunks(chunks, 0)
}
const allSegments = this.prependSegments(first.value, second.value, segments)
const chunks = this.expandOversizedSegments(allSegments, new ChunkBudget(this.maxChunks))
logger.info(`Chunked into ${chunks.length} strict-boundary regex chunks`)
return buildChunks(chunks, 0)
}
const first = segments.next()
const second = segments.next()
if (first.done || second.done) {
logger.warn(
'Regex pattern did not produce any splits, falling back to word-boundary splitting'
)
const chunkSizeChars = tokensToChars(this.chunkSize)
let chunks = splitAtWordBoundaries(cleaned, chunkSizeChars)
const budget = new ChunkBudget(this.maxChunks)
let chunks: string[] = []
for (const chunk of iterateWordBoundaryChunks(cleaned, chunkSizeChars)) {
budget.add(chunks, chunk)
}
if (this.chunkOverlap > 0) {
const overlapChars = tokensToChars(this.chunkOverlap)
chunks = addOverlap(chunks, overlapChars)
@@ -144,13 +167,9 @@ export class RegexChunker {
return buildChunks(chunks, this.chunkOverlap)
}
if (this.strictBoundaries) {
const chunks = this.expandOversizedSegments(segments)
logger.info(`Chunked into ${chunks.length} strict-boundary regex chunks`)
return buildChunks(chunks, 0)
}
const merged = this.mergeSegments(segments)
const allSegments = this.prependSegments(first.value, second.value, segments)
const budget = new ChunkBudget(this.maxChunks)
const merged = this.mergeSegments(allSegments, budget)
let chunks = merged
if (this.chunkOverlap > 0) {
@@ -162,12 +181,28 @@ export class RegexChunker {
return buildChunks(chunks, this.chunkOverlap)
}
private *nonEmptySegments(content: string): Generator<string> {
for (const segment of this.regex.iterateSplits(content)) {
if (segment.trim()) yield segment
}
}
private *prependSegments(
first: string,
second: string,
rest: Iterable<string>
): Generator<string> {
yield first
yield second
yield* rest
}
/**
* In strict-boundary mode each segment becomes its own chunk. Segments that
* exceed chunkSize are still split at word boundaries to preserve the token
* limit invariant; this is a safety floor, not a merge.
*/
private expandOversizedSegments(segments: string[]): string[] {
private expandOversizedSegments(segments: Iterable<string>, budget: ChunkBudget): string[] {
const result: string[] = []
const chunkSizeChars = tokensToChars(this.chunkSize)
@@ -176,11 +211,10 @@ export class RegexChunker {
if (!trimmed) continue
if (estimateTokens(trimmed) <= this.chunkSize) {
result.push(trimmed)
budget.add(result, trimmed)
} else {
const subChunks = splitAtWordBoundaries(trimmed, chunkSizeChars)
for (const sub of subChunks) {
if (sub.trim()) result.push(sub)
for (const sub of iterateWordBoundaryChunks(trimmed, chunkSizeChars)) {
if (sub.trim()) budget.add(result, sub)
}
}
}
@@ -188,7 +222,7 @@ export class RegexChunker {
return result
}
private mergeSegments(segments: string[]): string[] {
private mergeSegments(segments: Iterable<string>, budget: ChunkBudget): string[] {
const chunks: string[] = []
let current = ''
@@ -199,14 +233,13 @@ export class RegexChunker {
current = test
} else {
if (current.trim()) {
chunks.push(current.trim())
budget.add(chunks, current.trim())
}
if (estimateTokens(segment) > this.chunkSize) {
const chunkSizeChars = tokensToChars(this.chunkSize)
const subChunks = splitAtWordBoundaries(segment, chunkSizeChars)
for (const sub of subChunks) {
chunks.push(sub)
for (const sub of iterateWordBoundaryChunks(segment, chunkSizeChars)) {
budget.add(chunks, sub)
}
current = ''
} else {
@@ -216,7 +249,7 @@ export class RegexChunker {
}
if (current.trim()) {
chunks.push(current.trim())
budget.add(chunks, current.trim())
}
return chunks
+26 -20
View File
@@ -1,36 +1,45 @@
import { createLogger } from '@sim/logger'
import { ChunkBudget } from '@/lib/chunkers/chunk-budget'
import type { Chunk, SentenceChunkerOptions } from '@/lib/chunkers/types'
import {
buildChunks,
cleanText,
estimateTokens,
iterateWordBoundaryChunks,
resolveChunkerOptions,
splitAtWordBoundaries,
tokensToChars,
} from '@/lib/chunkers/utils'
const logger = createLogger('SentenceChunker')
const SENTENCE_BOUNDARY_PATTERN =
/(?<!\b(?:Mr|Mrs|Ms|Dr|Prof|Sr|Jr|St|Rev|Gen|Sgt|Capt|Lt|Col|Maj|No|Fig|Vol|Ch|vs|etc|Inc|Ltd|Corp|Co|approx|dept|est|govt|Ave|Blvd|Rd|Jan|Feb|Mar|Apr|Jun|Jul|Aug|Sep|Oct|Nov|Dec|i\.e|e\.g)\.)(?<![A-Z]\.)(?<!\.\.)(?<!\d\.)(?<=[.!?])\s+/g
/** Never splits mid-sentence unless a single sentence exceeds the limit. */
export class SentenceChunker {
private readonly chunkSize: number
private readonly chunkOverlap: number
private readonly minSentencesPerChunk: number
private readonly maxChunks?: number
constructor(options: SentenceChunkerOptions = {}) {
const resolved = resolveChunkerOptions(options)
this.chunkSize = resolved.chunkSize
this.chunkOverlap = resolved.chunkOverlap
this.minSentencesPerChunk = options.minSentencesPerChunk ?? 1
this.maxChunks = options.maxChunks
}
/** Splits on sentence boundaries while avoiding abbreviations, decimals, and ellipses. */
private splitSentences(text: string): string[] {
return text
.split(
/(?<!\b(?:Mr|Mrs|Ms|Dr|Prof|Sr|Jr|St|Rev|Gen|Sgt|Capt|Lt|Col|Maj|No|Fig|Vol|Ch|vs|etc|Inc|Ltd|Corp|Co|approx|dept|est|govt|Ave|Blvd|Rd|Jan|Feb|Mar|Apr|Jun|Jul|Aug|Sep|Oct|Nov|Dec|i\.e|e\.g)\.)(?<![A-Z]\.)(?<!\.\.)(?<!\d\.)(?<=[.!?])\s+/
)
.filter((s) => s.trim().length > 0)
private *splitSentences(text: string): Generator<string> {
let cursor = 0
for (const match of text.matchAll(SENTENCE_BOUNDARY_PATTERN)) {
const sentence = text.slice(cursor, match.index)
if (sentence.trim()) yield sentence
cursor = match.index + match[0].length
}
const tail = text.slice(cursor)
if (tail.trim()) yield tail
}
async chunk(content: string): Promise<Chunk[]> {
@@ -39,34 +48,31 @@ export class SentenceChunker {
}
const cleaned = cleanText(content)
const sentences = this.splitSentences(cleaned)
if (sentences.length === 0) {
return []
}
if (estimateTokens(cleaned) <= this.chunkSize) {
logger.info('Content fits in single chunk')
return buildChunks([cleaned], 0)
const texts: string[] = []
new ChunkBudget(this.maxChunks).add(texts, cleaned)
return buildChunks(texts, 0)
}
const budget = new ChunkBudget(this.maxChunks)
const chunkSentenceGroups: string[][] = []
let currentGroup: string[] = []
let currentTokens = 0
const chunkSizeChars = tokensToChars(this.chunkSize)
for (const sentence of sentences) {
for (const sentence of this.splitSentences(cleaned)) {
const sentenceTokens = estimateTokens(sentence)
if (sentenceTokens > this.chunkSize) {
if (currentGroup.length > 0) {
chunkSentenceGroups.push(currentGroup)
budget.add(chunkSentenceGroups, currentGroup)
currentGroup = []
currentTokens = 0
}
const parts = splitAtWordBoundaries(sentence, chunkSizeChars)
for (const part of parts) {
chunkSentenceGroups.push([part])
for (const part of iterateWordBoundaryChunks(sentence, chunkSizeChars)) {
budget.add(chunkSentenceGroups, [part])
}
continue
}
@@ -75,7 +81,7 @@ export class SentenceChunker {
const hasMinSentences = currentGroup.length >= this.minSentencesPerChunk
if (wouldExceed && hasMinSentences) {
chunkSentenceGroups.push(currentGroup)
budget.add(chunkSentenceGroups, currentGroup)
currentGroup = [sentence]
currentTokens = sentenceTokens
} else {
@@ -85,7 +91,7 @@ export class SentenceChunker {
}
if (currentGroup.length > 0) {
chunkSentenceGroups.push(currentGroup)
budget.add(chunkSentenceGroups, currentGroup)
}
const rawChunks = this.applyOverlapFromGroups(chunkSentenceGroups)
@@ -74,6 +74,30 @@ describe('StructuredDataChunker', () => {
expect(chunks).toEqual([])
})
it.each([
0,
-1,
0.5,
Number.NaN,
Number.NEGATIVE_INFINITY,
Number.POSITIVE_INFINITY,
Number.MAX_SAFE_INTEGER,
])('rejects an invalid chunk size before inspecting content: %s', async (chunkSize) => {
await expect(StructuredDataChunker.chunkStructuredData('', { chunkSize })).rejects.toThrow(
'Structured data chunk size must be a finite number between 1'
)
})
it('normalizes a fractional legacy chunk size to its integer token ceiling', async () => {
const chunks = await StructuredDataChunker.chunkStructuredData(
`value\n${'x'.repeat(1_000)}`,
{ chunkSize: 100.5 }
)
expect(chunks.length).toBeGreaterThan(1)
expect(chunks.every((chunk) => chunk.tokenCount <= 100)).toBe(true)
})
it.concurrent('should chunk basic CSV data', async () => {
const csv = `name,age,city
Alice,30,New York
@@ -128,6 +152,81 @@ Bob,25`
expect(chunks.length).toBeGreaterThan(1)
})
it('splits a maximum-length spreadsheet cell without dropping its content', async () => {
const value = `START-${'x'.repeat(32_755)}-END`
const chunks = await StructuredDataChunker.chunkStructuredData(`value\n${value}`, {
chunkSize: 1024,
})
expect(chunks.length).toBeGreaterThan(1)
expect(chunks.every((chunk) => chunk.tokenCount <= 1024)).toBe(true)
expect(chunks[0].text).toContain('START-')
expect(chunks.at(-1)?.text).toContain('-END')
})
it('splits an oversized header without repeating it into every row segment', async () => {
const header = `HEADER-${'h '.repeat(2_500)}-END`
const row = `ROW-${'r '.repeat(5_000)}-END`
const chunks = await StructuredDataChunker.chunkStructuredData(`${header}\n${row}`, {
chunkSize: 1024,
})
expect(chunks.length).toBeGreaterThan(1)
expect(chunks.length).toBeLessThan(20)
expect(chunks.every((chunk) => chunk.tokenCount <= 1024)).toBe(true)
expect(
chunks
.filter((chunk) => chunk.metadata.startIndex === 0)
.map((chunk) => chunk.text)
.join('')
).toBe(header)
expect(
chunks
.filter((chunk) => chunk.metadata.startIndex === 1)
.map((chunk) => chunk.text)
.join('')
).toBe(row)
expect(chunks.every((chunk) => !chunk.text.includes('Headers:'))).toBe(true)
expect(chunks.every((chunk) => !chunk.text.includes('rows of data'))).toBe(true)
})
it('preserves spaces when splitting an oversized structured row', async () => {
const row = 'AAAAAA BBBBBB'
const chunks = await StructuredDataChunker.chunkStructuredData(`value\n${row}`, {
chunkSize: 15,
})
const prefix = 'Headers: value\n-----\n'
const suffix = '\n\n[1 rows of data]'
const rowSegments = chunks
.filter((chunk) => chunk.metadata.startIndex === 1)
.map((chunk) => chunk.text.slice(prefix.length, -suffix.length))
expect(rowSegments.join('')).toBe(row)
expect(chunks.every((chunk) => chunk.tokenCount <= 15)).toBe(true)
})
it('does not let the minimum row target exceed the token target', async () => {
const row = 'x'.repeat(2_900)
const content = ['value', row, row, row, row, row].join('\n')
const chunks = await StructuredDataChunker.chunkStructuredData(content, { chunkSize: 1024 })
expect(chunks.length).toBe(5)
expect(chunks.every((chunk) => chunk.tokenCount <= 1024)).toBe(true)
})
it('accounts for labels, separators, sheet names, and footers before batching rows', async () => {
const content = ['h', ...Array.from({ length: 30 }, () => 'x')].join('\n')
const chunks = await StructuredDataChunker.chunkStructuredData(content, {
chunkSize: 20,
sheetName: 'S',
})
expect(chunks.length).toBeGreaterThan(1)
expect(chunks.every((chunk) => chunk.tokenCount <= 20)).toBe(true)
})
it.concurrent('should include token count in chunk metadata', async () => {
const csv = `name,age
Alice,30
+119 -28
View File
@@ -1,5 +1,11 @@
import { createLogger } from '@sim/logger'
import { ChunkBudget } from '@/lib/chunkers/chunk-budget'
import type { Chunk, StructuredDataOptions } from '@/lib/chunkers/types'
import {
iterateLines,
iterateLosslessWordBoundaryChunkSpans,
normalizeTokenChunkSize,
} from '@/lib/chunkers/utils'
/** Structured data is denser in tokens (~3 chars/token vs ~4 for prose) */
function estimateStructuredTokens(text: string): number {
@@ -7,6 +13,22 @@ function estimateStructuredTokens(text: string): number {
return Math.ceil(text.length / 3)
}
function estimateFormattedChunkTokens(
headerLine: string,
rowCharacters: number,
rowCount: number,
sheetName?: string
): number {
let characters = rowCharacters + Math.max(0, rowCount - 1)
if (sheetName) characters += 4 + sheetName.length + 6
if (DEFAULT_CONFIG.INCLUDE_HEADERS_IN_EACH_CHUNK) {
characters += 9 + headerLine.length + 1
characters += Math.min(80, headerLine.length) + 1
}
characters += 3 + String(rowCount).length + 14
return Math.ceil(characters / 3)
}
const logger = createLogger('StructuredDataChunker')
const DEFAULT_CONFIG = {
@@ -21,47 +43,107 @@ export class StructuredDataChunker {
content: string,
options: StructuredDataOptions = {}
): Promise<Chunk[]> {
const chunks: Chunk[] = []
const lines = content.split('\n').filter((line) => line.trim())
const targetChunkSize = normalizeTokenChunkSize(
options.chunkSize ?? DEFAULT_CONFIG.TARGET_CHUNK_SIZE,
'Structured data chunk size'
)
if (lines.length === 0) {
const chunks: Chunk[] = []
const sampleLines: string[] = []
for (const line of iterateLines(content)) {
if (!line.trim()) continue
sampleLines.push(line)
if (sampleLines.length === 10) break
}
if (sampleLines.length === 0) {
return chunks
}
const targetChunkSize = options.chunkSize ?? DEFAULT_CONFIG.TARGET_CHUNK_SIZE
const headerLine = options.headers?.join('\t') || lines[0]
const budget = new ChunkBudget(options.maxChunks)
const headerLine = options.headers?.join('\t') || sampleLines[0]
const dataStartIndex = options.headers ? 0 : 1
const estimatedTokensPerRow = StructuredDataChunker.estimateStructuredTokensPerRow(
lines.slice(dataStartIndex, Math.min(10, lines.length))
sampleLines.slice(dataStartIndex)
)
const optimalRowsPerChunk = StructuredDataChunker.calculateOptimalRowsPerChunk(
estimatedTokensPerRow,
targetChunkSize
)
logger.info(
`Structured data chunking: ${lines.length} rows, ~${estimatedTokensPerRow} tokens/row, ${optimalRowsPerChunk} rows/chunk, target: ${targetChunkSize} tokens`
)
let currentChunkRows: string[] = []
let currentTokenEstimate = 0
const headerTokens = estimateStructuredTokens(headerLine)
let currentRowsCharacters = 0
let chunkStartRow = dataStartIndex
let oversizedHeaderEmitted = false
for (let i = dataStartIndex; i < lines.length; i++) {
const row = lines[i]
const rowTokens = estimateStructuredTokens(row)
let lineIndex = 0
for (const row of iterateLines(content)) {
if (!row.trim()) continue
const i = lineIndex
lineIndex++
if (i < dataStartIndex) continue
const projectedTokens =
currentTokenEstimate +
rowTokens +
(DEFAULT_CONFIG.INCLUDE_HEADERS_IN_EACH_CHUNK ? headerTokens : 0)
const standaloneRow = StructuredDataChunker.formatChunk(headerLine, [row], options.sheetName)
if (estimateStructuredTokens(standaloneRow) > targetChunkSize) {
if (currentChunkRows.length > 0) {
const chunkContent = StructuredDataChunker.formatChunk(
headerLine,
currentChunkRows,
options.sheetName
)
budget.add(chunks, StructuredDataChunker.createChunk(chunkContent, chunkStartRow, i - 1))
currentChunkRows = []
currentRowsCharacters = 0
}
const emptyRowOverhead = estimateStructuredTokens(
StructuredDataChunker.formatChunk(headerLine, [''], options.sheetName)
)
if (emptyRowOverhead >= targetChunkSize) {
if (!oversizedHeaderEmitted) {
const headerContent = options.sheetName
? `${options.sheetName}\n${headerLine}`
: headerLine
const headerRow = Math.max(0, dataStartIndex - 1)
for (const segment of iterateLosslessWordBoundaryChunkSpans(
headerContent,
targetChunkSize * 3
)) {
budget.add(
chunks,
StructuredDataChunker.createChunk(segment.text, headerRow, headerRow)
)
}
oversizedHeaderEmitted = true
}
for (const segment of iterateLosslessWordBoundaryChunkSpans(row, targetChunkSize * 3)) {
budget.add(chunks, StructuredDataChunker.createChunk(segment.text, i, i))
}
chunkStartRow = i + 1
continue
}
const rowSegmentChars = Math.max(1, (targetChunkSize - emptyRowOverhead) * 3)
for (const segment of iterateLosslessWordBoundaryChunkSpans(row, rowSegmentChars)) {
const segmentContent = StructuredDataChunker.formatChunk(
headerLine,
[segment.text],
options.sheetName
)
budget.add(chunks, StructuredDataChunker.createChunk(segmentContent, i, i))
}
chunkStartRow = i + 1
continue
}
const projectedTokens = estimateFormattedChunkTokens(
headerLine,
currentRowsCharacters + row.length,
currentChunkRows.length + 1,
options.sheetName
)
const shouldCreateChunk =
(projectedTokens > targetChunkSize &&
currentChunkRows.length >= DEFAULT_CONFIG.MIN_ROWS_PER_CHUNK) ||
(projectedTokens > targetChunkSize && currentChunkRows.length > 0) ||
currentChunkRows.length >= optimalRowsPerChunk
if (shouldCreateChunk && currentChunkRows.length > 0) {
@@ -70,15 +152,15 @@ export class StructuredDataChunker {
currentChunkRows,
options.sheetName
)
chunks.push(StructuredDataChunker.createChunk(chunkContent, chunkStartRow, i - 1))
budget.add(chunks, StructuredDataChunker.createChunk(chunkContent, chunkStartRow, i - 1))
currentChunkRows = []
currentTokenEstimate = 0
currentRowsCharacters = 0
chunkStartRow = i
}
currentChunkRows.push(row)
currentTokenEstimate += rowTokens
currentRowsCharacters += row.length
}
if (currentChunkRows.length > 0) {
@@ -87,10 +169,15 @@ export class StructuredDataChunker {
currentChunkRows,
options.sheetName
)
chunks.push(StructuredDataChunker.createChunk(chunkContent, chunkStartRow, lines.length - 1))
budget.add(
chunks,
StructuredDataChunker.createChunk(chunkContent, chunkStartRow, lineIndex - 1)
)
}
logger.info(`Created ${chunks.length} chunks from ${lines.length} rows of structured data`)
logger.info(
`Created ${chunks.length} chunks from ${lineIndex} rows of structured data at ~${estimatedTokensPerRow} tokens/row and ${optimalRowsPerChunk} rows/chunk (target: ${targetChunkSize} tokens)`
)
return chunks
}
@@ -156,7 +243,11 @@ export class StructuredDataChunker {
}
}
const lines = content.split('\n').slice(0, 10)
const lines: string[] = []
for (const line of iterateLines(content)) {
lines.push(line)
if (lines.length === 10) break
}
if (lines.length < 2) return false
const delimiters = [',', '\t', '|']
+28 -19
View File
@@ -1,17 +1,21 @@
import { ChunkBudget } from '@/lib/chunkers/chunk-budget'
import type { Chunk, ChunkerOptions } from '@/lib/chunkers/types'
import {
addOverlap,
buildChunks,
cleanText,
estimateTokens,
hasMultipleNonEmptyLiteralParts,
iterateLiteralParts,
iterateWordBoundaryChunks,
resolveChunkerOptions,
splitAtWordBoundaries,
tokensToChars,
} from '@/lib/chunkers/utils'
export class TextChunker {
private readonly chunkSize: number
private readonly chunkOverlap: number
private readonly maxChunks?: number
private readonly separators = [
'\n---\n',
@@ -37,45 +41,51 @@ export class TextChunker {
const resolved = resolveChunkerOptions(options)
this.chunkSize = resolved.chunkSize
this.chunkOverlap = resolved.chunkOverlap
this.maxChunks = options.maxChunks
}
private splitRecursively(text: string, separatorIndex = 0): string[] {
private splitRecursively(
text: string,
chunks: string[],
budget: ChunkBudget,
separatorIndex = 0
): void {
const tokenCount = estimateTokens(text)
if (tokenCount <= this.chunkSize) {
return text.trim() ? [text] : []
if (text.trim()) budget.add(chunks, text)
return
}
if (separatorIndex >= this.separators.length) {
const chunkSizeChars = tokensToChars(this.chunkSize)
return splitAtWordBoundaries(text, chunkSizeChars)
for (const part of iterateWordBoundaryChunks(text, chunkSizeChars)) {
budget.add(chunks, part)
}
return
}
const separator = this.separators[separatorIndex]
const parts = text.split(separator).filter((part) => part.trim())
if (parts.length <= 1) {
return this.splitRecursively(text, separatorIndex + 1)
if (!hasMultipleNonEmptyLiteralParts(text, separator)) {
this.splitRecursively(text, chunks, budget, separatorIndex + 1)
return
}
const chunks: string[] = []
let currentChunk = ''
for (const part of parts) {
for (const part of iterateLiteralParts(text, separator)) {
if (!part.trim()) continue
const testChunk = currentChunk + (currentChunk ? separator : '') + part
if (estimateTokens(testChunk) <= this.chunkSize) {
currentChunk = testChunk
} else {
if (currentChunk.trim()) {
chunks.push(currentChunk.trim())
budget.add(chunks, currentChunk.trim())
}
if (estimateTokens(part) > this.chunkSize) {
const subChunks = this.splitRecursively(part, separatorIndex + 1)
for (const subChunk of subChunks) {
chunks.push(subChunk)
}
this.splitRecursively(part, chunks, budget, separatorIndex + 1)
currentChunk = ''
} else {
currentChunk = part
@@ -84,10 +94,8 @@ export class TextChunker {
}
if (currentChunk.trim()) {
chunks.push(currentChunk.trim())
budget.add(chunks, currentChunk.trim())
}
return chunks
}
async chunk(text: string): Promise<Chunk[]> {
@@ -96,7 +104,8 @@ export class TextChunker {
}
const cleaned = cleanText(text)
let chunks = this.splitRecursively(cleaned)
let chunks: string[] = []
this.splitRecursively(cleaned, chunks, new ChunkBudget(this.maxChunks))
if (this.chunkOverlap > 0) {
const overlapChars = tokensToChars(this.chunkOverlap)
+26 -9
View File
@@ -1,11 +1,12 @@
import { createLogger } from '@sim/logger'
import { ChunkBudget } from '@/lib/chunkers/chunk-budget'
import type { Chunk, ChunkerOptions } from '@/lib/chunkers/types'
import {
buildChunks,
cleanText,
estimateTokens,
iterateWordBoundaryChunks,
resolveChunkerOptions,
splitAtWordBoundaries,
tokensToChars,
} from '@/lib/chunkers/utils'
@@ -15,12 +16,14 @@ export class TokenChunker {
private readonly chunkSize: number
private readonly chunkOverlap: number
private readonly minCharactersPerChunk: number
private readonly maxChunks?: number
constructor(options: ChunkerOptions = {}) {
const resolved = resolveChunkerOptions(options)
this.chunkSize = resolved.chunkSize
this.chunkOverlap = resolved.chunkOverlap
this.minCharactersPerChunk = resolved.minCharactersPerChunk
this.maxChunks = options.maxChunks
}
async chunk(content: string): Promise<Chunk[]> {
@@ -29,24 +32,38 @@ export class TokenChunker {
}
const cleaned = cleanText(content)
const budget = new ChunkBudget(this.maxChunks)
if (estimateTokens(cleaned) <= this.chunkSize) {
logger.info('Content fits in single chunk')
return buildChunks([cleaned], 0)
const texts: string[] = []
budget.add(texts, cleaned)
return buildChunks(texts, 0)
}
const chunkSizeChars = tokensToChars(this.chunkSize)
const overlapChars = tokensToChars(this.chunkOverlap)
const stepChars = this.chunkOverlap > 0 ? chunkSizeChars - overlapChars : undefined
const rawChunks = splitAtWordBoundaries(cleaned, chunkSizeChars, stepChars)
const rawChunks: string[] = []
const filteredChunks: string[] = []
const filteredBudget = new ChunkBudget(this.maxChunks)
let rawChunkCount = 0
for (const chunk of iterateWordBoundaryChunks(cleaned, chunkSizeChars, stepChars)) {
rawChunkCount++
if (this.maxChunks === undefined || rawChunks.length <= this.maxChunks) {
rawChunks.push(chunk)
}
if (chunk.length >= this.minCharactersPerChunk) {
filteredBudget.add(filteredChunks, chunk)
}
}
const filtered =
rawChunks.length > 1
? rawChunks.filter((c) => c.length >= this.minCharactersPerChunk)
: rawChunks
const chunks = filtered.length > 0 ? filtered : rawChunks
let chunks = filteredChunks
if (rawChunkCount <= 1 || filteredChunks.length === 0) {
chunks = []
for (const chunk of rawChunks) budget.add(chunks, chunk)
}
logger.info(`Chunked into ${chunks.length} token-based chunks`)
return buildChunks(chunks, this.chunkOverlap)
+1
View File
@@ -7,6 +7,7 @@ export interface ChunkerOptions {
chunkSize?: number
chunkOverlap?: number
minCharactersPerChunk?: number
maxChunks?: number
}
export interface Chunk {
+42
View File
@@ -8,6 +8,7 @@ import {
buildChunks,
cleanText,
estimateTokens,
iterateLosslessWordBoundaryChunkSpans,
resolveChunkerOptions,
splitAtWordBoundaries,
tokensToChars,
@@ -108,6 +109,15 @@ describe('addOverlap', () => {
})
describe('splitAtWordBoundaries', () => {
it.each([0, -1, 0.5, Number.NaN, Number.POSITIVE_INFINITY])(
'rejects a non-positive, fractional, or non-finite chunk size: %s',
(chunkSize) => {
expect(() => splitAtWordBoundaries('bounded text', chunkSize)).toThrow(
'Word-boundary chunk size must be a positive safe integer'
)
}
)
it('returns single element for short text', () => {
const result = splitAtWordBoundaries('short text', 100)
expect(result).toHaveLength(1)
@@ -158,6 +168,28 @@ describe('splitAtWordBoundaries', () => {
})
})
describe('iterateLosslessWordBoundaryChunkSpans', () => {
it('preserves every source character while preferring word boundaries', () => {
const text = ' alpha beta gamma '
const spans = Array.from(iterateLosslessWordBoundaryChunkSpans(text, 8))
expect(spans.map((span) => span.text).join('')).toBe(text)
expect(spans.every((span) => span.text.length <= 8)).toBe(true)
for (const span of spans) {
expect(text.slice(span.startIndex, span.endIndex)).toBe(span.text)
}
})
it.each([0, -1, 0.5, Number.NaN, Number.POSITIVE_INFINITY])(
'rejects an invalid chunk size: %s',
(chunkSize) => {
expect(() =>
Array.from(iterateLosslessWordBoundaryChunkSpans('bounded text', chunkSize))
).toThrow('Lossless word-boundary chunk size must be a positive safe integer')
}
)
})
describe('buildChunks', () => {
it('creates Chunk objects with text, tokenCount, and metadata', () => {
const texts = ['hello world', 'foo bar']
@@ -204,6 +236,16 @@ describe('resolveChunkerOptions', () => {
expect(result.chunkOverlap).toBe(50)
})
it('normalizes a fractional legacy token size before deriving character windows', () => {
expect(resolveChunkerOptions({ chunkSize: 100.5 }).chunkSize).toBe(100)
})
it('rejects a token size whose character window would exceed safe integer bounds', () => {
expect(() => resolveChunkerOptions({ chunkSize: Number.MAX_SAFE_INTEGER })).toThrow(
'Chunk size must be a finite number between 1'
)
})
it('respects provided values when within limits', () => {
const result = resolveChunkerOptions({
chunkSize: 500,
+110 -11
View File
@@ -1,5 +1,7 @@
import type { Chunk } from '@/lib/chunkers/types'
const MAX_TOKEN_CHUNK_SIZE = Math.floor(Number.MAX_SAFE_INTEGER / 4)
/** 1 token ≈ 4 characters for English text */
export function estimateTokens(text: string): number {
if (!text?.trim()) return 0
@@ -10,6 +12,19 @@ export function tokensToChars(tokens: number): number {
return tokens * 4
}
export function assertPositiveSafeInteger(value: number, name: string): void {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive safe integer`)
}
}
export function normalizeTokenChunkSize(value: number, name: string): number {
if (!Number.isFinite(value) || value < 1 || value > MAX_TOKEN_CHUNK_SIZE) {
throw new Error(`${name} must be a finite number between 1 and ${MAX_TOKEN_CHUNK_SIZE}`)
}
return Math.floor(value)
}
export function cleanText(text: string): string {
return text
.replace(/\r\n/g, '\n')
@@ -60,7 +75,42 @@ export function splitAtWordBoundaries(
chunkSizeChars: number,
stepChars?: number
): string[] {
const parts: string[] = []
return Array.from(iterateWordBoundaryChunks(text, chunkSizeChars, stepChars))
}
export interface WordBoundaryChunkSpan {
text: string
startIndex: number
endIndex: number
}
/** Iterates bounded word-aware source slices without trimming or skipping characters. */
export function* iterateLosslessWordBoundaryChunkSpans(
text: string,
chunkSizeChars: number
): Generator<WordBoundaryChunkSpan> {
assertPositiveSafeInteger(chunkSizeChars, 'Lossless word-boundary chunk size')
let startIndex = 0
while (startIndex < text.length) {
let endIndex = Math.min(startIndex + chunkSizeChars, text.length)
if (endIndex < text.length) {
const lastSpace = text.lastIndexOf(' ', endIndex - 1)
if (lastSpace > startIndex) endIndex = lastSpace + 1
}
yield { text: text.slice(startIndex, endIndex), startIndex, endIndex }
startIndex = endIndex
}
}
/** Iterates trimmed word-boundary chunks while preserving their source offsets. */
export function* iterateWordBoundaryChunkSpans(
text: string,
chunkSizeChars: number,
stepChars?: number
): Generator<WordBoundaryChunkSpan> {
assertPositiveSafeInteger(chunkSizeChars, 'Word-boundary chunk size')
let pos = 0
while (pos < text.length) {
@@ -68,30 +118,79 @@ export function splitAtWordBoundaries(
if (end < text.length) {
const lastSpace = text.lastIndexOf(' ', end)
if (lastSpace > pos) {
end = lastSpace
}
if (lastSpace > pos) end = lastSpace
}
const part = text.slice(pos, end).trim()
if (part) {
parts.push(part)
const rawPart = text.slice(pos, end)
const startIndex = pos + (rawPart.length - rawPart.trimStart().length)
const endIndex = end - (rawPart.length - rawPart.trimEnd().length)
if (endIndex > startIndex) {
yield { text: text.slice(startIndex, endIndex), startIndex, endIndex }
}
if (stepChars !== undefined) {
// Sliding window: advance by step for predictable overlap
const nextPos = pos + Math.max(1, stepChars)
if (nextPos >= text.length) break
pos = nextPos
} else {
// Non-overlapping: advance from end of extracted content
if (end >= text.length) break
pos = end
}
while (pos < text.length && text[pos] === ' ') pos++
}
}
return parts
export function* iterateWordBoundaryChunks(
text: string,
chunkSizeChars: number,
stepChars?: number
): Generator<string> {
for (const span of iterateWordBoundaryChunkSpans(text, chunkSizeChars, stepChars)) {
yield span.text
}
}
/** Iterates literal-separated parts while preserving String.split's raw part values. */
export function* iterateLiteralParts(text: string, separator: string): Generator<string> {
if (!separator) {
yield text
return
}
let cursor = 0
while (cursor <= text.length) {
const next = text.indexOf(separator, cursor)
if (next === -1) {
yield text.slice(cursor)
return
}
yield text.slice(cursor, next)
cursor = next + separator.length
}
}
export function hasMultipleNonEmptyLiteralParts(text: string, separator: string): boolean {
let nonEmptyParts = 0
for (const part of iterateLiteralParts(text, separator)) {
if (!part.trim()) continue
nonEmptyParts++
if (nonEmptyParts === 2) return true
}
return false
}
/** Iterates lines without allocating an array proportional to line count. */
export function* iterateLines(text: string): Generator<string> {
let cursor = 0
while (cursor <= text.length) {
const next = text.indexOf('\n', cursor)
if (next === -1) {
yield text.slice(cursor)
return
}
yield text.slice(cursor, next)
cursor = next + 1
}
}
export function buildChunks(texts: string[], overlapTokens: number): Chunk[] {
@@ -133,7 +232,7 @@ export function resolveChunkerOptions(options: {
chunkOverlap?: number
minCharactersPerChunk?: number
}): { chunkSize: number; chunkOverlap: number; minCharactersPerChunk: number } {
const chunkSize = options.chunkSize ?? 1024
const chunkSize = normalizeTokenChunkSize(options.chunkSize ?? 1024, 'Chunk size')
const maxOverlap = Math.floor(chunkSize * 0.5)
return {
chunkSize,
@@ -157,6 +157,23 @@ describe('differential: split parity with the built-in engine', () => {
)
})
describe('differential: lazy split parity with eager split', () => {
it.each(SPLIT_PATTERNS.filter((pattern) => !isKnownDivergence(pattern)))(
'iterates %s identically to the eager linear split across every document',
(pattern) => {
const compiled = compile(pattern)
if (!compiled) return
for (const doc of DOCUMENTS) {
expect(
normalize(Array.from(compiled.iterateSplits(doc))),
`pattern ${pattern} on ${JSON.stringify(doc)}`
).toEqual(normalize(compiled.split(doc)))
}
}
)
})
describe('differential: test/find parity with the built-in engine', () => {
/** Grep-style patterns, where `test` and the match index are what matter. */
const MATCH_PATTERNS = [
@@ -131,6 +131,17 @@ describe('compileLookaroundSplit', () => {
)
})
it('keeps split independent of its object receiver', () => {
const doc = '# One\nalpha\n# Two\nbeta'
const { split } = compileLookaroundSplit('(?=#\\s)')!
expect(split(doc)).toEqual(doc.split(/(?=#\s)/g).filter(Boolean))
expect([doc, doc].map(split)).toEqual([
doc.split(/(?=#\s)/g).filter(Boolean),
doc.split(/(?=#\s)/g).filter(Boolean),
])
})
it('splits after each delimiter for (?<=X)', () => {
const doc = '<s>one</s><s>two</s><s>three</s>'
expect(compileLookaroundSplit('(?<=</s>)')?.split(doc)).toEqual([
+40 -21
View File
@@ -40,6 +40,7 @@ export interface LinearRegex {
* omitted — RE2 drops it, and every caller here discards empties anyway.
*/
split(text: string): string[]
iterateSplits(text: string): IterableIterator<string>
}
const METACHARACTERS = /[.*+?^${}()|[\]\\]/
@@ -284,28 +285,26 @@ export function compileLookaroundSplit(
return { start: matcher.start('mid'), end: matcher.end('mid') }
}
const iterateSplits = function* (text: string): Generator<string> {
let cursor = 0
let searchFrom = 0
while (searchFrom <= text.length) {
const span = delimiterAt(text, searchFrom)
if (!span) break
searchFrom = span.end > span.start ? span.end : span.start + 1
if (span.start < cursor || span.start >= text.length) continue
if (span.start === cursor && span.end === cursor) continue
yield text.slice(cursor, span.start)
cursor = span.end
}
if (cursor < text.length || cursor === 0) yield text.slice(cursor)
}
return {
test: (text) => compiled.matcher(text).find(),
find: (text) => delimiterAt(text, 0)?.start ?? -1,
split: (text) => {
const segments: string[] = []
let cursor = 0
let searchFrom = 0
while (searchFrom <= text.length) {
const span = delimiterAt(text, searchFrom)
if (!span) break
// Always advance, so a zero-width delimiter cannot spin.
searchFrom = span.end > span.start ? span.end : span.start + 1
// Skip a boundary behind the cursor, or one that would only emit an
// empty leading or trailing segment.
if (span.start < cursor || span.start >= text.length) continue
if (span.start === cursor && span.end === cursor) continue
segments.push(text.slice(cursor, span.start))
cursor = span.end
}
segments.push(text.slice(cursor))
return segments
},
iterateSplits,
split: (text) => Array.from(iterateSplits(text)),
}
}
@@ -329,8 +328,15 @@ export function literalRegex(pattern: string, options: LinearRegexOptions = {}):
const match = scanner.exec(text)
return match ? match.index : -1
},
// Built lazily: no caller splits on a literal, so the second compile is
// only paid if one ever does.
iterateSplits: function* (text) {
const splitter = new RegExp(source, `g${caseFlag}`)
let cursor = 0
for (const match of text.matchAll(splitter)) {
yield text.slice(cursor, match.index)
cursor = match.index + match[0].length
}
if (cursor < text.length || cursor === 0) yield text.slice(cursor)
},
split: (text) => text.split(new RegExp(source, `g${caseFlag}`)),
}
}
@@ -353,12 +359,25 @@ export function compileLinearRegex(
translateToRe2(pattern),
options.ignoreCase ? RE2JS.CASE_INSENSITIVE : 0
)
const iterateSplits = function* (text: string): Generator<string> {
const matcher = compiled.matcher(text)
let cursor = 0
while (matcher.find()) {
const start = matcher.start()
const end = matcher.end()
yield text.slice(cursor, start)
cursor = end
}
if (cursor < text.length || cursor === 0) yield text.slice(cursor)
}
return {
test: (text) => compiled.matcher(text).find(),
find: (text) => {
const matcher = compiled.matcher(text)
return matcher.find() ? matcher.start() : -1
},
iterateSplits,
split: (text) => compiled.split(text),
}
} catch {
@@ -4,6 +4,7 @@ import {
isSensitiveKey,
REDACTED_MARKER,
redactApiKeys,
redactExactSensitiveValues,
redactSensitiveValues,
sanitizeEventData,
sanitizeForLogging,
@@ -178,6 +179,298 @@ describe('redactSensitiveValues', () => {
expect(result).not.toContain('key123456')
})
it.concurrent('should redact form and percent-encoded OAuth credentials', () => {
const input =
'refresh_token=secret-one&client_secret=secret-two&oauth_token=secret-three&client_password=secret-four oauth_token%3Dsecret-five%26client_password%3Dsecret-six%26scope%3Dx'
const result = redactSensitiveValues(input)
expect(result).not.toContain('secret-one')
expect(result).not.toContain('secret-two')
expect(result).not.toContain('secret-three')
expect(result).not.toContain('secret-four')
expect(result).not.toContain('secret-five')
expect(result).not.toContain('secret-six')
expect(result).toContain('refresh_token=[REDACTED]')
expect(result).toContain('oauth_token%3D[REDACTED]')
expect(result).toContain('scope%3Dx')
})
it.concurrent('redacts authorization schemes without exposing the credential suffix', () => {
expect(redactSensitiveValues('authorization=Bearer <token> scope=openid')).toBe(
'authorization=[REDACTED] scope=openid'
)
expect(redactSensitiveValues('authorization=Basic dXNlcjpwYXNz')).toBe(
'authorization=[REDACTED]'
)
expect(redactSensitiveValues('authorization=Basic YQ== scope=openid')).toBe(
'authorization=[REDACTED] scope=openid'
)
expect(redactSensitiveValues('authorization=Bearer <padded-token>= scope=openid')).toBe(
'authorization=[REDACTED] scope=openid'
)
})
it.concurrent('redacts parameterized authorization schemes through the field delimiter', () => {
expect(
redactSensitiveValues(
'authorization=Digest username="user", realm="realm", response="digest-secret"&scope=openid'
)
).toBe('authorization=[REDACTED]&scope=openid')
expect(
redactSensitiveValues(
'proxyAuthorization=OAuth realm="Example", oauth_signature="oauth-secret"&scope=openid'
)
).toBe('proxyAuthorization=[REDACTED]&scope=openid')
})
it.concurrent('redacts parameterized authorization header values through the line ending', () => {
expect(
redactSensitiveValues(
'Authorization: Digest username="user", realm="realm", response="digest-secret"\nstatus: 401'
)
).toBe('Authorization: [REDACTED]\nstatus: 401')
expect(
redactSensitiveValues(
'Proxy-Authorization: AWS4-HMAC-SHA256 Credential=key/path, SignedHeaders=host, Signature=signature-secret'
)
).toBe('Proxy-Authorization: [REDACTED]')
})
it.concurrent(
'redacts malformed single-token authorization headers through the line ending',
() => {
expect(redactSensitiveValues('Authorization: Bearer prefix:secret-tail')).toBe(
'Authorization: Bearer [REDACTED]'
)
expect(redactSensitiveValues('Proxy-Authorization: Basic prefix:secret-tail')).toBe(
'Proxy-Authorization: Basic [REDACTED]'
)
}
)
it.concurrent('does not consume the next line when an authorization header is empty', () => {
expect(redactSensitiveValues('Authorization: Bearer\nstatus: 401')).toBe(
'Authorization: [REDACTED]\nstatus: 401'
)
expect(redactSensitiveValues('Authorization:\r\nstatus: 401')).toBe(
'Authorization: [REDACTED]\r\nstatus: 401'
)
})
it.concurrent('redacts encoded form credentials using delimiters at the matching depth', () => {
let encoded = new URLSearchParams({
access_token: 'prefix&secret-tail',
scope: 'openid',
}).toString()
for (let layer = 0; layer <= 3; layer++) {
const result = redactSensitiveValues(encoded)
expect(result).not.toContain('prefix')
expect(result).not.toContain('secret-tail')
expect(result).toContain('scope')
encoded = encodeURIComponent(encoded)
}
})
it.concurrent('uses the canonical sensitive-key policy for form fields', () => {
const keys = ['authorization', 'auth', 'bearer', 'private_key', 'passphrase']
const input = keys
.flatMap((key, index) => [`${key}=plain-secret-${index}`, `${key}%3Dencoded-secret-${index}`])
.join('&')
const result = redactSensitiveValues(input)
for (let index = 0; index < keys.length; index++) {
expect(result).not.toContain(`plain-secret-${index}`)
expect(result).not.toContain(`encoded-secret-${index}`)
}
})
it.concurrent('classifies percent-encoded form keys after bounded decoding', () => {
const input =
'api%5Fkey=secret-one&private%2Dkey=secret-two&cl%69ent%5Fsecret=secret-three&display%5Fname=Alice&next%50age%54oken=cursor'
expect(redactSensitiveValues(input)).toBe(
'api%5Fkey=[REDACTED]&private%2Dkey=[REDACTED]&cl%69ent%5Fsecret=[REDACTED]&display%5Fname=Alice&next%50age%54oken=cursor'
)
})
it.concurrent('redacts repeatedly encoded authorization keys through their delimiter', () => {
const input =
'proxy%252Dauthorization%253DDigest username="user", response="secret"%2526scope%253Dopenid'
expect(redactSensitiveValues(input)).toBe(
'proxy%252Dauthorization%253D[REDACTED]%2526scope%253Dopenid'
)
})
it.concurrent('fails closed for malformed or excessively encoded form keys', () => {
for (const malformedKey of [
'api%ZZkey',
'api%G_key',
'private%2/key',
'authorization%?%?',
'authorization%?%3Fscope',
'api%?%26scope',
'authorization%FF%3Fscope',
'api%C0%26scope',
'api%',
]) {
expect(redactSensitiveValues(`${malformedKey}=secret-one&scope=openid`)).toBe(
`${malformedKey}=[REDACTED]&scope=openid`
)
}
expect(redactSensitiveValues('api%2525255Fkey=secret-two&scope=openid')).toBe(
'api%2525255Fkey=[REDACTED]&scope=openid'
)
expect(redactSensitiveValues('authorization%E2%82%AC%3Fscope=public&next=ok')).toBe(
'authorization%E2%82%AC%3Fscope=public&next=ok'
)
})
it.concurrent('preserves safe form keys containing an encoded literal percent', () => {
expect(redactSensitiveValues('discount%25value=10&scope=openid')).toBe(
'discount%25value=10&scope=openid'
)
})
it.concurrent('redacts sensitive raw fields nested inside a non-sensitive URL value', () => {
const input = 'redirect_uri=https://example.com/callback?access_token=raw-secret&scope=openid'
expect(redactSensitiveValues(input)).toBe(
'redirect_uri=https://example.com/callback?access_token=[REDACTED]&scope=openid'
)
})
it.concurrent('redacts a raw sensitive value containing an encoded ampersand in full', () => {
const input = 'access_token=prefix%26secret-tail&scope=openid'
expect(redactSensitiveValues(input)).toBe('access_token=[REDACTED]&scope=openid')
})
it.concurrent(
'redacts sensitive percent-encoded fields nested inside a non-sensitive URL value',
() => {
const input =
'redirect_uri%3Dhttps%3A%2F%2Fexample.com%2Fcallback%3Faccess_token%3Dencoded-secret%26scope%3Dopenid'
expect(redactSensitiveValues(input)).toBe(
'redirect_uri%3Dhttps%3A%2F%2Fexample.com%2Fcallback%3Faccess_token%3D[REDACTED]%26scope%3Dopenid'
)
}
)
it.concurrent('redacts authorization fields after repeatedly encoded query separators', () => {
const cases = [
[
'%3Fauthorization%3DBearer%20nested-secret%26scope%3Dopenid',
'%3Fauthorization%3D[REDACTED]%26scope%3Dopenid',
],
[
'%253Fauthorization%253DBearer%2520nested-secret%2526scope%253Dopenid',
'%253Fauthorization%253D[REDACTED]%2526scope%253Dopenid',
],
[
'%25253Fauthorization%25253DBearer%252520nested-secret%252526scope%25253Dopenid',
'%25253Fauthorization%25253D[REDACTED]%252526scope%25253Dopenid',
],
]
for (const [input, expected] of cases) {
expect(redactSensitiveValues(input)).toBe(expected)
}
})
it.concurrent('redacts authorization fields inside repeatedly encoded URL values', () => {
let input =
'redirect_uri=https%3A%2F%2Fexample.com%2Fcallback%3Fauthorization%3DBearer%20nested-secret%26scope%3Dopenid'
for (let depth = 0; depth < 2; depth++) {
const result = redactSensitiveValues(input)
expect(result).not.toContain('nested-secret')
expect(result).toContain('authorization')
expect(result).toContain('scope')
input = encodeURIComponent(input)
}
})
it.concurrent('redacts an encoded nested authorization field after a long safe prefix', () => {
const prefix = 'x'.repeat(400_000)
const field = '%3Fauthorization%3DBearer%20nested-secret%26scope%3Dopenid'
expect(redactSensitiveValues(prefix + field)).toBe(
`${prefix}%3Fauthorization%3D[REDACTED]%26scope%3Dopenid`
)
})
it.concurrent('does not end a sensitive value at an encoded query separator', () => {
expect(redactSensitiveValues('access_token=prefix%3Fscope%3Dsecret-tail&next=ok')).toBe(
'access_token=[REDACTED]&next=ok'
)
})
it.concurrent('preserves safe fields after an encoded query separator', () => {
const input = 'redirect_uri=https%3A%2F%2Fexample.com%2F%3Fpage%3Dhome%26scope%3Dopenid'
expect(redactSensitiveValues(input)).toBe(input)
})
it.concurrent('preserves non-secret pagination tokens in form-encoded strings', () => {
const input =
'nextPageToken=page-one nextPageToken%3Dpage-two nextpagetoken=page-three NEXTPAGETOKEN%3Dpage-four'
expect(redactSensitiveValues(input)).toBe(input)
})
it.concurrent('should redact exact secrets echoed in free-form text', () => {
const result = redactExactSensitiveValues(
'provider echoed s3cr%2Ft, s3cr%2ft, space+secret, and plain s3cr/t',
['s3cr/t', 'space secret']
)
expect(result).not.toContain('s3cr/t')
expect(result).not.toContain('s3cr%2Ft')
expect(result).not.toContain('s3cr%2ft')
expect(result).not.toContain('space+secret')
})
it.concurrent('redacts overlapping secrets longest-first', () => {
const result = redactExactSensitiveValues('provider echoed abcSECRET', ['abc', 'abcSECRET'])
expect(result).toBe('provider echoed [REDACTED]')
expect(result).not.toContain('SECRET')
})
it.concurrent('redacts mixed-case percent escapes', () => {
const result = redactExactSensitiveValues('provider echoed %2f%3A', ['/:'])
expect(result).toBe('provider echoed [REDACTED]')
})
it.concurrent('redacts exact secrets through bounded repeated encoding', () => {
const secret = 'path/secret'
const encoded = encodeURIComponent(encodeURIComponent(encodeURIComponent(secret)))
expect(redactExactSensitiveValues(`provider echoed ${encoded}`, [secret])).toBe(
'provider echoed [REDACTED]'
)
})
it.concurrent(
'redacts exact secrets containing raw form delimiters before parsing fields',
() => {
const secret = 'prefix&secret-tail'
expect(redactExactSensitiveValues(`echo access_token=${secret}`, [secret])).toBe(
'echo access_token=[REDACTED]'
)
}
)
it.concurrent('redacts exact secrets containing whitespace before generic auth parsing', () => {
const secret = 'password with spaces'
expect(redactExactSensitiveValues(`Basic ${secret}`, [secret])).toBe('Basic [REDACTED]')
})
it.concurrent('should not modify safe strings', () => {
const input = 'This is a normal string with no secrets'
const result = redactSensitiveValues(input)
+352 -5
View File
@@ -7,7 +7,7 @@ import { filterUserFileForDisplay, isUserFile } from '@/lib/core/utils/user-file
export const REDACTED_MARKER = '[REDACTED]'
export const TRUNCATED_MARKER = '[TRUNCATED]'
const BYPASS_REDACTION_KEYS = new Set(['nextPageToken'])
const BYPASS_REDACTION_KEYS = new Set(['nextpagetoken'])
/** Keys that contain large binary/encoded data that should be truncated in logs */
const LARGE_DATA_KEYS = new Set(['base64'])
@@ -28,6 +28,7 @@ const SENSITIVE_KEY_PATTERNS: RegExp[] = [
/^.*api[_-]?key$/i,
/^passphrase$/i,
/^authorization$/i,
/^proxy[_-]?authorization$/i,
/^bearer$/i,
/^private$/i,
/^auth$/i,
@@ -41,6 +42,17 @@ const SENSITIVE_VALUE_PATTERNS: Array<{
pattern: RegExp
replacement: string
}> = [
// Single-token authorization headers retain their scheme for diagnostics.
{
pattern: /\b((?:proxy[_-]?)?authorization[ \t]*:[ \t]*(?:Bearer|Basic)[ \t]+)[^\r\n]*/gi,
replacement: `$1${REDACTED_MARKER}`,
},
// Parameterized and unknown authorization headers fail closed through the line ending.
{
pattern:
/\b((?:proxy[_-]?)?authorization[ \t]*:)(?![ \t]*(?:Bearer|Basic)[ \t])[ \t]*[^\r\n]*/gi,
replacement: `$1 ${REDACTED_MARKER}`,
},
// Bearer tokens
{
pattern: /Bearer\s+[A-Za-z0-9\-._~+/]+=*/gi,
@@ -73,14 +85,313 @@ const SENSITIVE_VALUE_PATTERNS: Array<{
},
]
const FORM_FIELD_MARKER_PATTERN = /=|%(?:25)*3D/i
const ENCODED_FORM_KEY_COMPONENT_PATTERN = /%[0-9A-F]{2}/i
const FORM_WHITESPACE_PATTERN = /\s/u
const AUTHORIZATION_FORM_KEYS = new Set(['authorization', 'proxyauthorization'])
const SINGLE_TOKEN_AUTHORIZATION_PATTERN = /^(?:Bearer|Basic)\s+\S+/i
const MAX_EXACT_SECRET_ENCODING_LAYERS = 3
interface ActiveSensitiveFormField {
start: number
encodingDepth: number
whitespaceBoundaryAfter?: number
}
interface NormalizedFormKey {
value: string
complete: boolean
}
interface FormKeySpan {
endIndex: number
malformed: boolean
}
type EncodedFormMarkerKind = 'delimiter' | 'field' | 'query'
interface EncodedFormMarker {
kind: EncodedFormMarkerKind
text: string
}
type FormFieldPrefixKind =
| 'start'
| 'delimiter'
| 'encoded-delimiter'
| 'encoded-query'
| 'whitespace'
| 'other'
interface FormFieldToken {
kind: 'field'
index: number
endIndex: number
key: string
fieldMarker: string
prefixKind: FormFieldPrefixKind
prefixMarker?: string
}
interface FormDelimiterToken {
kind: 'delimiter'
index: number
delimiter: string
}
type FormToken = FormFieldToken | FormDelimiterToken
export function isSensitiveKey(key: string): boolean {
if (BYPASS_REDACTION_KEYS.has(key)) {
return false
}
const lowerKey = key.toLowerCase()
if (BYPASS_REDACTION_KEYS.has(lowerKey)) return false
return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(lowerKey))
}
function getFormEncodingDepth(marker: string): number {
return marker.length === 1 ? 0 : (marker.length - 1) / 2
}
function normalizeFormKey(key: string): string {
return key.toLowerCase().replaceAll('_', '').replaceAll('-', '')
}
function decodeFormKey(key: string): NormalizedFormKey {
let value = key
let decodedLayer = false
for (let layer = 0; layer < MAX_EXACT_SECRET_ENCODING_LAYERS; layer++) {
if (!ENCODED_FORM_KEY_COMPONENT_PATTERN.test(value)) {
return { value, complete: decodedLayer || !value.includes('%') }
}
try {
value = decodeURIComponent(value)
decodedLayer = true
} catch {
return { value, complete: false }
}
}
return { value, complete: !ENCODED_FORM_KEY_COMPONENT_PATTERN.test(value) }
}
function isAuthorizationFormKey(key: string): boolean {
return AUTHORIZATION_FORM_KEYS.has(normalizeFormKey(key))
}
function isRawFormKeyCharacter(character: string): boolean {
const code = character.charCodeAt(0)
return (
(code >= 48 && code <= 57) ||
(code >= 65 && code <= 90) ||
(code >= 97 && code <= 122) ||
character === '_' ||
character === '-'
)
}
function readEncodedFormMarker(value: string, index: number): EncodedFormMarker | undefined {
if (value[index] !== '%') return undefined
let cursor = index + 1
while (value.slice(cursor, cursor + 2).toLowerCase() === '25') cursor += 2
const code = value.slice(cursor, cursor + 2).toLowerCase()
const kind: EncodedFormMarkerKind | undefined =
code === '26' ? 'delimiter' : code === '3d' ? 'field' : code === '3f' ? 'query' : undefined
if (!kind) return undefined
return { kind, text: value.slice(index, cursor + 2) }
}
function readFormKeySpan(value: string, start: number): FormKeySpan {
let cursor = start
let malformed = false
while (cursor < value.length) {
if (isRawFormKeyCharacter(value[cursor])) {
cursor++
continue
}
if (value[cursor] !== '%') break
const encodedMarker = readEncodedFormMarker(value, cursor)
if (encodedMarker) {
if (encodedMarker.kind === 'field') break
if (!malformed && !decodeFormKey(value.slice(start, cursor)).complete) malformed = true
if (!malformed) break
cursor += encodedMarker.text.length
continue
}
const componentStart = cursor
cursor++
for (let offset = 0; offset < 2 && cursor < value.length; offset++) {
const character = value[cursor]
if (
character === '%' ||
character === '&' ||
character === '=' ||
FORM_WHITESPACE_PATTERN.test(character)
) {
break
}
cursor++
}
if (!ENCODED_FORM_KEY_COMPONENT_PATTERN.test(value.slice(componentStart, cursor))) {
malformed = true
}
}
return { endIndex: cursor, malformed }
}
/** Iterates form fields and delimiters without regex backtracking over provider-controlled input. */
function* iterateFormTokens(value: string): Generator<FormToken> {
let index = 0
while (index < value.length) {
const encodedPrefix = readEncodedFormMarker(value, index)
let prefixKind: FormFieldPrefixKind | undefined
let keyStart = index
let prefixMarker: string | undefined
if (encodedPrefix?.kind === 'delimiter' || encodedPrefix?.kind === 'query') {
prefixKind = encodedPrefix.kind === 'delimiter' ? 'encoded-delimiter' : 'encoded-query'
prefixMarker = encodedPrefix.text
keyStart += encodedPrefix.text.length
} else if (value[index] === '&') {
prefixKind = 'delimiter'
keyStart++
} else if (FORM_WHITESPACE_PATTERN.test(value[index])) {
prefixKind = 'whitespace'
while (keyStart < value.length && FORM_WHITESPACE_PATTERN.test(value[keyStart])) keyStart++
} else if (!isRawFormKeyCharacter(value[index]) && value[index] !== '%') {
prefixKind = 'other'
keyStart++
} else if (index === 0) {
prefixKind = 'start'
}
if (prefixKind) {
const { endIndex: keyEnd } = readFormKeySpan(value, keyStart)
const encodedFieldMarker = readEncodedFormMarker(value, keyEnd)
const fieldMarker =
value[keyEnd] === '='
? '='
: encodedFieldMarker?.kind === 'field'
? encodedFieldMarker.text
: undefined
if (keyEnd > keyStart && fieldMarker) {
const endIndex = keyEnd + fieldMarker.length
yield {
kind: 'field',
index,
endIndex,
key: value.slice(keyStart, keyEnd),
fieldMarker,
prefixKind,
prefixMarker,
}
index = endIndex
continue
}
if (encodedPrefix?.kind === 'delimiter') {
yield { kind: 'delimiter', index, delimiter: encodedPrefix.text }
index += encodedPrefix.text.length
continue
}
if (value[index] === '&') {
yield { kind: 'delimiter', index, delimiter: '&' }
index++
continue
}
index = Math.max(index + 1, keyEnd)
continue
}
if (isRawFormKeyCharacter(value[index])) {
do index++
while (index < value.length && isRawFormKeyCharacter(value[index]))
continue
}
index++
}
}
function findWhitespaceRunStart(value: string, end: number): number {
let start = end
while (start > 0 && /\s/u.test(value[start - 1])) start--
return start
}
function redactSensitiveFormFields(value: string): string {
if (!FORM_FIELD_MARKER_PATTERN.test(value)) return value
let result = ''
let cursor = 0
let activeField: ActiveSensitiveFormField | undefined
const closeActiveField = (end: number) => {
if (!activeField) return
if (end > activeField.start) {
result += `${value.slice(cursor, activeField.start)}${REDACTED_MARKER}`
cursor = end
}
activeField = undefined
}
for (const token of iterateFormTokens(value)) {
if (token.kind === 'field') {
if (activeField && token.prefixKind !== 'start') {
const boundaryIndex =
token.prefixKind === 'whitespace'
? findWhitespaceRunStart(value, token.index)
: token.index
const closesActiveField =
token.prefixKind === 'delimiter' ||
(token.prefixKind === 'encoded-delimiter' &&
token.prefixMarker !== undefined &&
getFormEncodingDepth(token.prefixMarker) === activeField.encodingDepth) ||
(token.prefixKind === 'whitespace' &&
activeField.whitespaceBoundaryAfter !== undefined &&
boundaryIndex >= activeField.whitespaceBoundaryAfter)
if (closesActiveField) closeActiveField(boundaryIndex)
}
const normalizedKey = decodeFormKey(token.key)
if (!activeField && (!normalizedKey.complete || isSensitiveKey(normalizedKey.value))) {
const start = token.endIndex
const authorization = normalizedKey.complete && isAuthorizationFormKey(normalizedKey.value)
const singleTokenAuthorization = authorization
? value.slice(start).match(SINGLE_TOKEN_AUTHORIZATION_PATTERN)?.[0]
: undefined
activeField = {
start,
encodingDepth: getFormEncodingDepth(token.fieldMarker),
whitespaceBoundaryAfter: authorization
? singleTokenAuthorization === undefined
? undefined
: start + singleTokenAuthorization.length
: normalizedKey.complete
? start
: undefined,
}
}
continue
}
if (!activeField) continue
if (
token.delimiter === '&' ||
getFormEncodingDepth(token.delimiter) === activeField.encodingDepth
) {
closeActiveField(token.index)
}
}
closeActiveField(value.length)
return cursor === 0 ? value : result + value.slice(cursor)
}
/**
* Redacts sensitive patterns from a string value
* @param value - The string to redact
@@ -91,13 +402,49 @@ export function redactSensitiveValues(value: string): string {
return value
}
let result = value
let result = redactSensitiveFormFields(value)
for (const { pattern, replacement } of SENSITIVE_VALUE_PATTERNS) {
result = result.replace(pattern, replacement)
}
return result
}
/**
* Redacts known secret values in all literal and URL-encoded forms before the
* generic pattern pass. Exact replacement must run first because a credential
* can itself contain form delimiters that would otherwise split it and leave a
* suffix visible before the exact matcher sees the original value.
*/
export function redactKnownSensitiveValues(value: string, secrets: string[]): string {
let result = value
const orderedSecrets = [...new Set(secrets.filter(Boolean))].sort(
(left, right) => right.length - left.length
)
for (const secret of orderedSecrets) {
result = result.replaceAll(secret, REDACTED_MARKER)
const encodedVariants = new Set<string>()
let uriEncoded = secret
let formEncoded = secret
for (let layer = 0; layer < MAX_EXACT_SECRET_ENCODING_LAYERS; layer++) {
uriEncoded = encodeURIComponent(uriEncoded)
formEncoded = new URLSearchParams({ value: formEncoded }).toString().slice('value='.length)
encodedVariants.add(uriEncoded)
encodedVariants.add(formEncoded)
}
for (const encoded of encodedVariants) {
if (encoded !== secret) {
const escaped = encoded.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
result = result.replace(new RegExp(escaped, 'gi'), REDACTED_MARKER)
}
}
}
return result
}
export function redactExactSensitiveValues(value: string, secrets: string[]): string {
return redactSensitiveValues(redactKnownSensitiveValues(value, secrets))
}
export function isLargeDataKey(key: string): boolean {
return LARGE_DATA_KEYS.has(key)
}
@@ -185,6 +185,65 @@ describe('stream limits', () => {
).rejects.toBeInstanceOf(PayloadSizeLimitError)
})
it('does not let the text convenience reader bypass the bodyless fail-closed rule', async () => {
const text = vi.fn(async () => 'small but not independently bounded')
await expect(
readResponseTextWithLimit(
{ body: null, text },
{ maxBytes: 100, label: 'unknown text response' }
)
).rejects.toBeInstanceOf(PayloadSizeLimitError)
expect(text).not.toHaveBeenCalled()
})
it('rejects an undeclared bodyless response even without a fallback materializer', async () => {
await expect(
readResponseToBufferWithLimit(
{ body: null },
{ maxBytes: 100, label: 'unknown empty response' }
)
).rejects.toBeInstanceOf(PayloadSizeLimitError)
})
it('allows a bodyless fallback only with a trusted length or explicit declaration', async () => {
const declared = await readResponseTextWithLimit(
{ headers: headers('5'), body: null, text: async () => 'hello' },
{ maxBytes: 5, label: 'declared response' }
)
const explicitlyBounded = await readResponseTextWithLimit(
{ body: null, text: async () => 'hello' },
{ maxBytes: 5, label: 'trusted fallback', allowNoBodyFallback: true }
)
expect(declared).toBe('hello')
expect(explicitlyBounded).toBe('hello')
})
it.each([204, 205, 304])('accepts a semantically bodyless HTTP %i response', async (status) => {
const text = vi.fn(async () => 'must not be materialized')
const result = await readResponseTextWithLimit(
{ status, headers: headers('1000'), body: null, text },
{ maxBytes: 100, label: 'bodyless response' }
)
expect(result).toBe('')
expect(text).not.toHaveBeenCalled()
})
it('accepts a semantically bodyless HEAD response', async () => {
const text = vi.fn(async () => 'must not be materialized')
const result = await readResponseTextWithLimit(
{ status: 200, headers: headers('1000'), body: null, text },
{ maxBytes: 100, label: 'HEAD response', requestMethod: 'HEAD' }
)
expect(result).toBe('')
expect(text).not.toHaveBeenCalled()
})
it('cancels when the abort signal is already aborted', async () => {
const controller = new AbortController()
controller.abort(new Error('stop'))
+14 -7
View File
@@ -295,10 +295,12 @@ export interface ReadResponseWithLimitOptions extends ReadStreamWithLimitOptions
headers?: { get(name: string): string | null }
preferTextFallback?: boolean
allowNoBodyFallback?: boolean
requestMethod?: string
}
export async function readResponseToBufferWithLimit(
response: {
status?: number
headers?: { get(name: string): string | null }
body?: ReadableStream<Uint8Array> | null
arrayBuffer?: () => Promise<ArrayBuffer>
@@ -306,6 +308,16 @@ export async function readResponseToBufferWithLimit(
},
options: ReadResponseWithLimitOptions
): Promise<Buffer> {
const isSemanticallyBodyless =
response.status === 204 ||
response.status === 205 ||
response.status === 304 ||
options.requestMethod?.toUpperCase() === 'HEAD'
if (isSemanticallyBodyless) {
await response.body?.cancel().catch(() => {})
return Buffer.alloc(0)
}
const contentLength = getContentLength(response.headers ?? options.headers)
try {
if (contentLength !== null) {
@@ -317,13 +329,7 @@ export async function readResponseToBufferWithLimit(
}
throw error
}
if (
!options.allowNoBodyFallback &&
!options.preferTextFallback &&
!response.body &&
contentLength === null &&
(response.arrayBuffer || response.text)
) {
if (!options.allowNoBodyFallback && !response.body && contentLength === null) {
throw new PayloadSizeLimitError({
label: options.label,
maxBytes: options.maxBytes,
@@ -357,6 +363,7 @@ export async function readResponseToBufferWithLimit(
export async function readResponseTextWithLimit(
response: {
status?: number
headers?: { get(name: string): string | null }
body?: ReadableStream<Uint8Array> | null
arrayBuffer?: () => Promise<ArrayBuffer>
+512 -69
View File
@@ -4,13 +4,19 @@
import { resetEnvMock, setEnv } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
clampEmbeddingConcurrency,
EMBEDDING_MAX_RETRIES,
EmbeddingAPIError,
EmbeddingOutputLimitError,
EmbeddingQuotaExhaustedError,
embed,
embedKnowledgeForDeployment,
embedOpenRouter,
isEmbeddingQuotaExhaustion,
isTransientEmbeddingError,
MAX_EMBEDDING_SUCCESS_RESPONSE_BYTES,
} from '@/lib/embeddings/client'
import { resetEmbeddingQuotaCircuitsForTesting } from '@/lib/embeddings/quota-circuit'
const { mockGetBYOKKey } = vi.hoisted(() => ({
mockGetBYOKKey: vi.fn(),
@@ -30,24 +36,55 @@ vi.mock('@/lib/api-key/byok', () => ({
const originalFetch = global.fetch
function jsonResponse(body: unknown, status = 200, responseHeaders?: HeadersInit): Response {
return {
ok: status >= 200 && status < 300,
return new Response(JSON.stringify(body), {
status,
statusText: String(status),
// A real Response always carries these; the failure path reads them for rate-limit signals.
headers: new Headers(responseHeaders),
json: async () => body,
text: async () => JSON.stringify(body),
} as Response
})
}
function openAIBody(vectors: number[][], totalTokens = 5) {
function rawJsonResponse(body: string, status = 200): Response {
return new Response(body, {
status,
statusText: String(status),
headers: new Headers({ 'content-type': 'application/json' }),
})
}
function sizedVector(values: number[], dimensions: number): number[] {
return [...values, ...Array(Math.max(0, dimensions - values.length)).fill(0)].slice(0, dimensions)
}
function openAIBody(vectors: number[][], totalTokens = 5, dimensions: number | null = 1536) {
return {
data: vectors.map((embedding) => ({ embedding })),
data: vectors.map((embedding) => ({
embedding: dimensions === null ? embedding : sizedVector(embedding, dimensions),
})),
usage: { total_tokens: totalTokens },
}
}
function oversizedChunkedSuccessResponse(): Response {
const chunkBytes = 1024 * 1024
const chunk = new Uint8Array(chunkBytes).fill(0x20)
const chunkCount = Math.floor(MAX_EMBEDDING_SUCCESS_RESPONSE_BYTES / chunkBytes) + 1
let emitted = 0
return new Response(
new ReadableStream<Uint8Array>({
pull(controller) {
if (emitted >= chunkCount) {
controller.close()
return
}
controller.enqueue(chunk)
emitted++
},
}),
{ status: 200 }
)
}
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
@@ -68,6 +105,7 @@ beforeEach(() => {
})
afterEach(() => {
resetEmbeddingQuotaCircuitsForTesting()
global.fetch = originalFetch
vi.useRealTimers()
vi.restoreAllMocks()
@@ -90,7 +128,8 @@ describe('embed', () => {
input: ['hello'],
model: 'text-embedding-3-small',
})
expect(result.embeddings).toEqual([[1, 2, 3]])
expect(result.embeddings[0].slice(0, 3)).toEqual([1, 2, 3])
expect(result.embeddings[0]).toHaveLength(1536)
expect(result.totalTokens).toBe(4)
expect(result.dimensions).toBe(1536)
expect(result.pricingId).toBe('text-embedding-3-small')
@@ -104,7 +143,9 @@ describe('embed', () => {
const body = JSON.parse((init as RequestInit).body as string)
const count = body.requests.length
// Each vector encodes its global input index so ordering is verifiable.
const embeddings = Array.from({ length: count }, (_, i) => ({ values: [cursor + i] }))
const embeddings = Array.from({ length: count }, (_, i) => ({
values: sizedVector([cursor + i], 3072),
}))
cursor += count
return jsonResponse({ embeddings })
})
@@ -126,7 +167,9 @@ describe('embed', () => {
})
it('estimates tokens when the provider omits usage', async () => {
fetchMock.mockResolvedValue(jsonResponse({ embeddings: [{ values: [1, 2] }] }))
fetchMock.mockResolvedValue(
jsonResponse({ embeddings: [{ values: sizedVector([1, 2], 3072) }] })
)
const result = await embed(['some text to embed'], {
model: 'gemini-embedding-001',
@@ -139,7 +182,7 @@ describe('embed', () => {
it("bills Gemini on its reported token count rather than tiktoken's guess", async () => {
fetchMock.mockResolvedValue(
jsonResponse({
embeddings: [{ values: [1, 2] }],
embeddings: [{ values: sizedVector([1, 2], 3072) }],
usageMetadata: { promptTokenCount: 4321 },
})
)
@@ -174,11 +217,40 @@ describe('embed', () => {
expect(result.embeddings).toHaveLength(40)
})
it('splits max-dimension batches to the successful-response byte budget and preserves order', async () => {
fetchMock.mockImplementation(async (_url, init) => {
const body = JSON.parse((init as RequestInit).body as string)
const inputs = body.input as string[]
return jsonResponse(
openAIBody(
inputs.map((input) => [Number(input.slice(1))]),
inputs.length,
3072
)
)
})
const inputs = Array.from({ length: 400 }, (_, index) => `i${index}`)
const result = await embed(inputs, {
model: 'text-embedding-3-large',
apiKey: 'sk-test',
})
expect(
fetchMock.mock.calls.map(
([, init]) => JSON.parse((init as RequestInit).body as string).input.length
)
).toEqual([169, 169, 62])
expect(result.embeddings.map(([value]) => value)).toEqual(
inputs.map((input) => Number(input.slice(1)))
)
})
it('keeps a long Cohere input whole rather than cutting it to the batch budget', async () => {
fetchMock.mockImplementation(async (_url, init) => {
const body = JSON.parse((init as RequestInit).body as string)
return jsonResponse({
embeddings: { float: body.texts.map(() => [1]) },
embeddings: { float: body.texts.map(() => sizedVector([1], 1536)) },
meta: { billed_units: { input_tokens: 1 } },
})
})
@@ -196,7 +268,7 @@ describe('embed', () => {
})
it('forwards a supported dimension reduction and reports it back', async () => {
fetchMock.mockResolvedValue(jsonResponse(openAIBody([[1, 2]])))
fetchMock.mockResolvedValue(jsonResponse(openAIBody([[1, 2]], 5, 1024)))
const result = await embed(['hello'], {
model: 'text-embedding-3-large',
@@ -231,7 +303,7 @@ describe('embed', () => {
it('omits the dimension field for a model without Matryoshka support', async () => {
fetchMock.mockResolvedValue(
jsonResponse({
data: [{ embedding: [1, 2], index: 0 }],
data: [{ embedding: sizedVector([1, 2], 1024), index: 0 }],
usage: { total_tokens: 5 },
})
)
@@ -258,15 +330,132 @@ describe('embed', () => {
})
it('surfaces a non-retryable provider error with its status', async () => {
fetchMock.mockResolvedValue(jsonResponse({ error: 'bad key' }, 401))
const echoedSecret = 'sk-provider-echoed-secret'
fetchMock.mockResolvedValue(jsonResponse({ error: `bad key: ${echoedSecret}` }, 401))
await expect(
embed(['hello'], { model: 'text-embedding-3-small', apiKey: 'sk-bad' })
).rejects.toThrow(/Embedding API failed: 401/)
const error = await embed(['hello'], {
model: 'text-embedding-3-small',
apiKey: 'sk-bad',
}).catch((caught) => caught)
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toMatch(/Embedding API failed: 401/)
expect((error as Error).message).not.toContain(echoedSecret)
// 401 is not retryable, so exactly one attempt is made.
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('rejects an oversized chunked success response before JSON materialization', async () => {
fetchMock.mockResolvedValue(oversizedChunkedSuccessResponse())
await expect(
embed(['hello'], { model: 'text-embedding-3-small', apiKey: 'sk-test' })
).rejects.toMatchObject({
name: 'PayloadSizeLimitError',
label: 'Embedding API success response',
maxBytes: MAX_EMBEDDING_SUCCESS_RESPONSE_BYTES,
})
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it.each([
{
name: 'the wrong number of vectors',
inputs: ['alpha', 'beta'],
body: openAIBody([[1]], 2),
message: 'returned 1 embeddings for 2 inputs',
},
{
name: 'an empty vector',
inputs: ['alpha'],
body: openAIBody([[]], 1, null),
message: 'vector 0 is empty or not an array',
},
{
name: 'a vector with the wrong catalog dimension',
inputs: ['alpha'],
body: openAIBody([[1, 2]], 1, null),
message: 'vector 0 has 2 unexpected dimensions; expected 1536',
},
{
name: 'a vector with a nonnumeric coordinate',
inputs: ['alpha'],
body: { data: [{ embedding: [1, 'invalid'] }], usage: { total_tokens: 1 } },
message: 'vector 0 contains a non-numeric or non-finite coordinate',
},
{
name: 'an unparseable vector envelope',
inputs: ['alpha'],
body: { data: {}, usage: { total_tokens: 1 } },
message: 'the vector payload could not be parsed',
},
])('rejects a valid-JSON success body containing $name', async ({ inputs, body, message }) => {
fetchMock.mockResolvedValue(jsonResponse(body))
await expect(
embed(inputs, { model: 'text-embedding-3-small', apiKey: 'sk-test' })
).rejects.toMatchObject({
name: 'EmbeddingResponseValidationError',
message: expect.stringContaining(message),
})
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('rejects a valid-JSON success body containing a non-finite coordinate', async () => {
fetchMock.mockResolvedValue(
rawJsonResponse('{"data":[{"embedding":[1e999]}],"usage":{"total_tokens":1}}')
)
await expect(
embed(['alpha'], { model: 'text-embedding-3-small', apiKey: 'sk-test' })
).rejects.toMatchObject({
name: 'EmbeddingResponseValidationError',
message: expect.stringContaining('non-numeric or non-finite coordinate'),
})
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('rejects one malformed concurrent batch after admitting all independent batches', async () => {
const inputs = Array.from({ length: 3 }, (_, index) => `i${index} ${'word '.repeat(5000)}`)
fetchMock.mockImplementation(async (_url, init) => {
const body = JSON.parse((init as RequestInit).body as string)
const input = (body.input as string[])[0]
return input.startsWith('i1')
? jsonResponse({ data: [], usage: { total_tokens: 1 } })
: jsonResponse(openAIBody([[Number(input[1])]], 1))
})
await expect(
embed(inputs, { model: 'text-embedding-3-small', apiKey: 'sk-test' })
).rejects.toMatchObject({
name: 'EmbeddingResponseValidationError',
message: expect.stringContaining('returned 0 embeddings for 1 inputs'),
})
expect(fetchMock).toHaveBeenCalledTimes(3)
})
it('preserves input order when concurrent batches complete out of order', async () => {
const inputs = Array.from({ length: 3 }, (_, index) => `i${index} ${'word '.repeat(5000)}`)
const responders = new Map<string, (response: Response) => void>()
fetchMock.mockImplementation(
async (_url, init) =>
new Promise<Response>((resolve) => {
const body = JSON.parse((init as RequestInit).body as string)
const input = (body.input as string[])[0]
responders.set(input.slice(0, 2), resolve)
})
)
const pending = embed(inputs, { model: 'text-embedding-3-small', apiKey: 'sk-test' })
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3))
for (const index of [2, 1, 0]) {
responders.get(`i${index}`)?.(jsonResponse(openAIBody([[index]], 1)))
}
const result = await pending
expect(result.embeddings.map(([value]) => value)).toEqual([0, 1, 2])
})
it('retries a rate-limited request and succeeds on a later attempt', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ error: 'slow down' }, 429))
@@ -278,7 +467,7 @@ describe('embed', () => {
})
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(result.embeddings).toEqual([[7, 8]])
expect(result.embeddings[0].slice(0, 2)).toEqual([7, 8])
})
it('marks a caller-supplied key as BYOK so Sim does not bill for it', async () => {
@@ -294,7 +483,7 @@ describe('embed', () => {
})
it('uses OpenRouter as an explicit transport for an OpenAI catalog model', async () => {
fetchMock.mockResolvedValue(jsonResponse(openAIBody([[1, 2]])))
fetchMock.mockResolvedValue(jsonResponse(openAIBody([[1, 2]], 5, 1024)))
await embed(['hello'], {
model: 'text-embedding-3-large',
@@ -320,7 +509,9 @@ describe('embed', () => {
*/
describe('per-model token limits', () => {
it("truncates against Gemini's lower ceiling rather than a shared constant", async () => {
fetchMock.mockResolvedValue(jsonResponse({ embeddings: [{ values: [1] }] }))
fetchMock.mockResolvedValue(
jsonResponse({ embeddings: [{ values: sizedVector([1], 3072) }] })
)
// ~10k tokens: over Gemini's 2048 ceiling, but under the old 8000 constant,
// so this used to reach the provider whole and come back a 502.
const long = 'word '.repeat(8000)
@@ -334,7 +525,10 @@ describe('embed', () => {
it("keeps text intact up to Cohere's much higher ceiling", async () => {
fetchMock.mockResolvedValue(
jsonResponse({ embeddings: { float: [[1]] }, meta: { billed_units: { input_tokens: 9 } } })
jsonResponse({
embeddings: { float: [sizedVector([1], 1536)] },
meta: { billed_units: { input_tokens: 9 } },
})
)
// Over the old 8000 constant, well under Cohere's 128k, so it must survive.
const long = 'word '.repeat(8000)
@@ -382,7 +576,9 @@ describe('embed', () => {
it('estimates tokens from the projected values, not the originals', async () => {
// Gemini omits usage, so the token count is estimated from what was sent.
fetchMock.mockResolvedValue(jsonResponse({ embeddings: [{ values: [1, 2, 3] }] }))
fetchMock.mockResolvedValue(
jsonResponse({ embeddings: [{ values: sizedVector([1, 2, 3], 3072) }] })
)
const result = await embed(['x'.repeat(400)], {
model: 'gemini-embedding-001',
@@ -403,7 +599,9 @@ describe('embed', () => {
* shortening one discarded content that would have fit.
*/
it('batches the projected text, not the original', async () => {
fetchMock.mockResolvedValue(jsonResponse({ embeddings: [{ values: [1] }] }))
fetchMock.mockResolvedValue(
jsonResponse({ embeddings: [{ values: sizedVector([1], 3072) }] })
)
// Under Gemini's 2048 ceiling before projection, far over it after.
const short = 'secret'
@@ -439,17 +637,17 @@ describe('embed', () => {
describe('embedOpenRouter', () => {
it('uses a dynamic model and reports the returned native dimensions', async () => {
fetchMock.mockResolvedValue(
jsonResponse(
fetchMock.mockImplementation(async (_url, init) => {
const body = JSON.parse((init as RequestInit).body as string)
const inputs = body.input as string[]
return jsonResponse(
openAIBody(
[
[1, 2, 3],
[4, 5, 6],
],
7
inputs.map((input) => (input === 'alpha' ? [1, 2, 3] : [4, 5, 6])),
inputs[0] === 'alpha' ? 3 : 4,
null
)
)
)
})
const result = await embedOpenRouter(['alpha', 'beta'], {
model: 'openrouter/qwen/qwen3-embedding-8b',
@@ -461,9 +659,13 @@ describe('embedOpenRouter', () => {
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('https://openrouter.ai/api/v1/embeddings')
expect(JSON.parse((init as RequestInit).body as string)).toMatchObject({
input: ['alpha', 'beta'],
input: ['alpha'],
model: 'qwen/qwen3-embedding-8b',
})
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(
JSON.parse((fetchMock.mock.calls[1][1] as RequestInit).body as string)
).not.toHaveProperty('dimensions')
expect(result).toMatchObject({
embeddings: [
[1, 2, 3],
@@ -478,33 +680,96 @@ describe('embedOpenRouter', () => {
})
it('fails when OpenRouter returns the wrong number of vectors', async () => {
fetchMock.mockResolvedValue(jsonResponse(openAIBody([[1, 2]])))
fetchMock.mockResolvedValue(jsonResponse(openAIBody([[1, 2]], 5, null)))
await expect(
embedOpenRouter(['alpha', 'beta'], {
model: 'openrouter/qwen/qwen3-embedding-8b',
apiKey: 'or-test',
maxInputTokens: 32768,
dimensions: 2,
projectInputs: null,
})
).rejects.toThrow('returned 1 embeddings for 2 inputs')
})
it('fails when OpenRouter returns inconsistent vector dimensions', async () => {
fetchMock.mockResolvedValue(jsonResponse(openAIBody([[1, 2], [3]])))
fetchMock
.mockResolvedValueOnce(jsonResponse(openAIBody([[1, 2]], 1, null)))
.mockResolvedValueOnce(jsonResponse(openAIBody([[3, 4], [5]], 2, null)))
await expect(
embedOpenRouter(['alpha', 'beta'], {
embedOpenRouter(['alpha', 'beta', 'gamma'], {
model: 'openrouter/qwen/qwen3-embedding-8b',
apiKey: 'or-test',
maxInputTokens: 32768,
projectInputs: null,
})
).rejects.toThrow('inconsistent dimensions')
).rejects.toThrow('vector 1 has 1 unexpected dimensions; expected 2')
})
it('fails when OpenRouter violates an explicitly requested dimension', async () => {
fetchMock.mockResolvedValue(jsonResponse(openAIBody([[1, 2]], 5, null)))
await expect(
embedOpenRouter(['alpha'], {
model: 'openrouter/qwen/qwen3-embedding-8b',
apiKey: 'or-test',
maxInputTokens: 32768,
dimensions: 3,
projectInputs: null,
})
).rejects.toThrow('vector 0 has 2 unexpected dimensions; expected 3')
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('rejects a dynamic-model batch that differs from the learned dimension', async () => {
const inputs = Array.from({ length: 2049 }, (_, index) => `i${index}`)
fetchMock.mockImplementation(async (_url, init) => {
const body = JSON.parse((init as RequestInit).body as string)
const batch = body.input as string[]
const embedding = batch.length === 1 ? [2, 3, 4] : [1, 3]
return jsonResponse(
openAIBody(
batch.map(() => embedding),
batch.length,
null
)
)
})
await expect(
embedOpenRouter(inputs, {
model: 'openrouter/qwen/qwen3-embedding-8b',
apiKey: 'or-test',
maxInputTokens: 32768,
projectInputs: null,
})
).rejects.toThrow('vector 0 has 2 unexpected dimensions; expected 3')
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('treats OpenRouter HTTP 402 as exhausted credit and opens the circuit', async () => {
fetchMock.mockResolvedValue(jsonResponse({ error: { message: 'Payment required' } }, 402))
const options = {
model: 'openrouter/qwen/qwen3-embedding-8b',
apiKey: 'or-exhausted',
maxInputTokens: 32768,
projectInputs: null,
} as const
await expect(embedOpenRouter(['alpha'], options)).rejects.toEqual(
expect.objectContaining({ name: 'EmbeddingQuotaExhaustedError', status: 402 })
)
await expect(embedOpenRouter(['beta'], options)).rejects.toBeInstanceOf(
EmbeddingQuotaExhaustedError
)
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('truncates inputs to the selected model context length', async () => {
fetchMock.mockResolvedValue(jsonResponse(openAIBody([[1, 2]])))
fetchMock.mockResolvedValue(jsonResponse(openAIBody([[1, 2]], 5, null)))
await embedOpenRouter(['alpha beta gamma'], {
model: 'openrouter/thenlper/gte-base',
@@ -526,7 +791,8 @@ describe('embedOpenRouter', () => {
return jsonResponse(
openAIBody(
inputs.map((input) => [Number(input.slice(1))]),
inputs.length
inputs.length,
null
)
)
})
@@ -549,6 +815,87 @@ describe('embedOpenRouter', () => {
expect(result.embeddings[0]).toEqual([0])
expect(result.embeddings[2048]).toEqual([2048])
})
it('learns a dynamic model dimension before response-safe batching', async () => {
const dimensions = 32_768
const inputs = Array.from({ length: 17 }, (_, index) => `i${index}`)
fetchMock.mockImplementation(async (_url, init) => {
const body = JSON.parse((init as RequestInit).body as string)
const batch = body.input as string[]
return jsonResponse(
openAIBody(
batch.map((input) => sizedVector([Number(input.slice(1))], dimensions)),
batch.length,
null
)
)
})
const result = await embedOpenRouter(inputs, {
model: 'openrouter/example/high-dimensional-model',
apiKey: 'or-test',
maxInputTokens: 32768,
projectInputs: null,
})
expect(
fetchMock.mock.calls
.map(([, init]) => JSON.parse((init as RequestInit).body as string).input.length)
.sort((a, b) => a - b)
).toEqual([1, 1, 15])
expect(result.dimensions).toBe(dimensions)
expect(result.embeddings.map(([first]) => first)).toEqual(
Array.from({ length: 17 }, (_, index) => index)
)
})
it('rejects an oversized dynamic aggregate after discovery and before fan-out', async () => {
const dimensions = 32_768
fetchMock.mockResolvedValue(jsonResponse(openAIBody([sizedVector([1], dimensions)], 1, null)))
await expect(
embedOpenRouter(
Array.from({ length: 100 }, (_, index) => `i${index}`),
{
model: 'openrouter/example/high-dimensional-model',
apiKey: 'or-test',
maxInputTokens: 32768,
projectInputs: null,
}
)
).rejects.toThrow('Embedding output')
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('rejects an oversized explicit-dimension aggregate before making a request', async () => {
await expect(
embedOpenRouter(
Array.from({ length: 1000 }, (_, index) => `i${index}`),
{
model: 'openrouter/example/high-dimensional-model',
apiKey: 'or-test',
maxInputTokens: 32768,
dimensions: 4096,
projectInputs: null,
}
)
).rejects.toThrow('Embedding output')
expect(fetchMock).not.toHaveBeenCalled()
})
})
describe('embedding concurrency admission', () => {
it.each([
[Number.NaN, 8],
[0, 1],
[-10, 1],
[4.9, 4],
[10_000, 16],
])('clamps %s to %s', (configured, expected) => {
expect(clampEmbeddingConcurrency(configured)).toBe(expected)
})
})
describe('knowledge embedding transport fallback', () => {
@@ -573,12 +920,25 @@ describe('knowledge embedding transport fallback', () => {
dimensions: 1536,
})
expect(result).toMatchObject({
embeddings: [[1, 2]],
billableTokens: 3,
isBYOK: false,
modelName: 'text-embedding-3-small',
dimensions: 1536,
})
expect(result.embeddings[0].slice(0, 2)).toEqual([1, 2])
expect(result.embeddings[0]).toHaveLength(1536)
})
it('rejects aggregate output above the safe limit before selecting a transport', async () => {
setEnv({ OPENROUTER_API_KEY: 'or-test' })
const texts = Array.from({ length: 5000 }, (_, index) => `input-${index}`)
const projectInputs = vi.fn((inputs: string[]) => inputs)
await expect(
embedKnowledgeForDeployment(texts, { ...options, projectInputs }, false)
).rejects.toBeInstanceOf(EmbeddingOutputLimitError)
expect(projectInputs).not.toHaveBeenCalled()
expect(fetchMock).not.toHaveBeenCalled()
})
it('keeps the original OpenAI path when OpenRouter is not configured', async () => {
@@ -634,7 +994,9 @@ describe('knowledge embedding transport fallback', () => {
it('does not use OpenRouter for non-OpenAI knowledge models', async () => {
setEnv({ GEMINI_API_KEY: 'gemini-test', OPENROUTER_API_KEY: 'or-test' })
fetchMock.mockResolvedValue(jsonResponse({ embeddings: [{ values: [1, 2] }] }))
fetchMock.mockResolvedValue(
jsonResponse({ embeddings: [{ values: sizedVector([1, 2], 1536) }] })
)
await embedKnowledgeForDeployment(
['hello'],
@@ -666,6 +1028,41 @@ describe('knowledge embedding transport fallback', () => {
expect(fetchMock).toHaveBeenCalledOnce()
})
it('falls back once when a provider returns a malformed success body', async () => {
setEnv({ OPENAI_API_KEY: 'openai-test', OPENROUTER_API_KEY: 'or-test' })
fetchMock.mockImplementation(async (url) =>
url === 'https://api.openai.com/v1/embeddings'
? jsonResponse({ data: [], usage: { total_tokens: 1 } })
: jsonResponse(openAIBody([[7, 8]], 2))
)
const result = await embedKnowledgeForDeployment(['hello'], options, false)
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
'https://api.openai.com/v1/embeddings',
'https://openrouter.ai/api/v1/embeddings',
])
expect(result.embeddings[0].slice(0, 2)).toEqual([7, 8])
})
it('falls back immediately when the first provider credential has exhausted credit', async () => {
setEnv({ OPENAI_API_KEY: 'openai-test', OPENROUTER_API_KEY: 'or-test' })
fetchMock.mockImplementation(async (url) =>
url === 'https://api.openai.com/v1/embeddings'
? jsonResponse({ error: { type: 'insufficient_quota', code: 'insufficient_quota' } }, 429)
: jsonResponse(openAIBody([[7, 8]], 2))
)
const result = await embedKnowledgeForDeployment(['hello'], options, false)
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
'https://api.openai.com/v1/embeddings',
'https://openrouter.ai/api/v1/embeddings',
])
expect(result.embeddings[0].slice(0, 2)).toEqual([7, 8])
})
it('falls back after transient retries and projects inputs only once', async () => {
vi.useFakeTimers()
setEnv({ OPENAI_API_KEY: 'openai-test', OPENROUTER_API_KEY: 'or-test' })
@@ -687,7 +1084,7 @@ describe('knowledge embedding transport fallback', () => {
).toBe(true)
expect(fetchMock.mock.calls[attempts][0]).toBe('https://openrouter.ai/api/v1/embeddings')
expect(projectInputs).toHaveBeenCalledOnce()
expect(result.embeddings).toEqual([[7, 8]])
expect(result.embeddings[0].slice(0, 2)).toEqual([7, 8])
})
it('falls back only the failed batch and retains successful provider work', async () => {
@@ -719,7 +1116,7 @@ describe('knowledge embedding transport fallback', () => {
expect(openRouterInputs).toEqual([secondInput])
// The succeeding batch, every attempt on the failing one, then its fallback.
expect(fetchMock).toHaveBeenCalledTimes(1 + (EMBEDDING_MAX_RETRIES + 1) + 1)
expect(result.embeddings).toEqual([[1], [2]])
expect(result.embeddings.map(([value]) => value)).toEqual([1, 2])
expect(result.totalTokens).toBe(6)
expect(result.billableTokens).toBe(3)
expect(result.isBYOK).toBe(false)
@@ -784,7 +1181,7 @@ describe('knowledge embedding transport fallback', () => {
const openAICalls = fetchMock.mock.calls.filter(([url]) => url.includes('api.openai.com'))
expect(openAICalls).toHaveLength(1)
expect(result.embeddings).toEqual([[9, 9]])
expect(result.embeddings[0].slice(0, 2)).toEqual([9, 9])
})
/**
@@ -819,7 +1216,7 @@ describe('knowledge embedding transport fallback', () => {
const result = await pending
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(result.embeddings).toEqual([[4, 4]])
expect(result.embeddings[0].slice(0, 2)).toEqual([4, 4])
})
/**
@@ -827,33 +1224,65 @@ describe('knowledge embedding transport fallback', () => {
* sync — so retrying one burns the budget per document, indefinitely.
*/
it('does not retry a 429 that reports an exhausted balance', async () => {
vi.useFakeTimers()
setEnv({ OPENAI_API_KEY: 'openai-test' })
const fetchMock = vi.fn().mockImplementation(
async () =>
({
ok: false,
status: 429,
statusText: 'Too Many Requests',
headers: new Headers(),
json: async () => ({}),
text: async () =>
JSON.stringify({
error: {
message: 'You have no credits remaining.',
type: 'insufficient_quota',
code: 'credit_balance_exhausted',
},
}),
}) as Response
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse(
{
error: {
message: 'You have no credits remaining.',
type: 'insufficient_quota',
code: 'credit_balance_exhausted',
},
},
429
)
)
vi.stubGlobal('fetch', fetchMock)
const pending = embed(['hello'], { ...options, apiKey: 'openai-test' }).catch((e) => e)
await vi.runAllTimersAsync()
const error = await pending
await expect(embed(['hello'], { ...options, apiKey: 'openai-test' })).rejects.toEqual(
expect.objectContaining({ name: 'EmbeddingQuotaExhaustedError', quotaExhausted: true })
)
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('classifies quota JSON beyond the diagnostic truncation boundary', async () => {
fetchMock.mockResolvedValue(
jsonResponse(
{
diagnosticPadding: 'x'.repeat(10_000),
error: {
message: 'You have no credits remaining.',
type: 'insufficient_quota',
code: 'insufficient_quota',
},
},
429
)
)
await expect(embed(['hello'], { ...options, apiKey: 'large-quota-body-key' })).rejects.toEqual(
expect.objectContaining({
name: 'EmbeddingQuotaExhaustedError',
status: 429,
})
)
expect(fetchMock).toHaveBeenCalledOnce()
})
it('short-circuits later requests that use the exhausted credential', async () => {
const quotaResponse = jsonResponse(
{ error: { type: 'insufficient_quota', code: 'insufficient_quota' } },
429
)
fetchMock.mockResolvedValue(quotaResponse)
await expect(embed(['first'], { ...options, apiKey: 'exhausted-key' })).rejects.toBeInstanceOf(
EmbeddingQuotaExhaustedError
)
await expect(embed(['second'], { ...options, apiKey: 'exhausted-key' })).rejects.toBeInstanceOf(
EmbeddingQuotaExhaustedError
)
expect(error).toBeInstanceOf(EmbeddingAPIError)
expect(fetchMock).toHaveBeenCalledTimes(1)
})
@@ -887,7 +1316,7 @@ describe('knowledge embedding transport fallback', () => {
const result = await pending
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(result.embeddings).toEqual([[5, 5]])
expect(result.embeddings[0].slice(0, 2)).toEqual([5, 5])
})
/**
@@ -900,6 +1329,20 @@ describe('knowledge embedding transport fallback', () => {
expect(isTransientEmbeddingError(error)).toBe(true)
})
it('classifies aggregate quota exhaustion only when every fallback exhausted credit', () => {
const openAIQuota = new EmbeddingQuotaExhaustedError('openai')
const openRouterQuota = new EmbeddingQuotaExhaustedError('openrouter')
expect(isEmbeddingQuotaExhaustion(new AggregateError([openAIQuota, openRouterQuota]))).toBe(
true
)
expect(
isEmbeddingQuotaExhaustion(
new AggregateError([openAIQuota, new EmbeddingAPIError('temporarily unavailable', 503)])
)
).toBe(false)
})
it('classifies only transient embedding failures for failover', () => {
expect(isTransientEmbeddingError(new EmbeddingAPIError('unavailable', 503))).toBe(true)
expect(isTransientEmbeddingError(new EmbeddingAPIError('rate limited', 429))).toBe(true)
+363 -58
View File
@@ -10,6 +10,11 @@ import {
} from '@/lib/core/config/env-capabilities'
import { isHosted } from '@/lib/core/config/env-flags'
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
import {
DEFAULT_MAX_ERROR_BODY_BYTES,
readResponseJsonWithLimit,
readResponseTextWithLimit,
} from '@/lib/core/utils/stream-limits'
import {
DEFAULT_EMBEDDING_MODEL,
type EmbeddingModelInfo,
@@ -20,9 +25,16 @@ import {
import { resolveProviderKey } from '@/lib/embeddings/keys'
import { DEFAULT_OPENROUTER_EMBEDDING_MODEL } from '@/lib/embeddings/openrouter-models'
import { getAdapterFactory } from '@/lib/embeddings/providers'
import {
createEmbeddingQuotaCircuitIdentity,
type EmbeddingQuotaCircuitIdentity,
isEmbeddingQuotaCircuitOpen,
openEmbeddingQuotaCircuit,
} from '@/lib/embeddings/quota-circuit'
import { resolveEmbeddingRetryDelayMs } from '@/lib/embeddings/rate-limit'
import type {
EmbeddingProviderAdapter,
EmbeddingProviderKind,
EmbeddingTaskType,
EmbedOptions,
EmbedResult,
@@ -48,7 +60,27 @@ const logger = createLogger('EmbeddingClient')
* product reached four figures of in-flight requests against one key — enough to
* hold a provider at its limit indefinitely, which no retry policy can absorb.
*/
const MAX_CONCURRENT_BATCHES = envNumber(env.KB_CONFIG_EMBEDDING_CONCURRENCY, 8)
const DEFAULT_CONCURRENT_BATCHES = 8
const MAX_ALLOWED_CONCURRENT_BATCHES = 16
/** Keeps one worker's fan-out inside a tested local memory/concurrency ceiling. */
export function clampEmbeddingConcurrency(value: number): number {
if (!Number.isFinite(value)) return DEFAULT_CONCURRENT_BATCHES
return Math.min(Math.max(Math.floor(value), 1), MAX_ALLOWED_CONCURRENT_BATCHES)
}
const configuredEmbeddingConcurrency = envNumber(
env.KB_CONFIG_EMBEDDING_CONCURRENCY,
DEFAULT_CONCURRENT_BATCHES
)
const MAX_CONCURRENT_BATCHES = clampEmbeddingConcurrency(configuredEmbeddingConcurrency)
if (configuredEmbeddingConcurrency !== MAX_CONCURRENT_BATCHES) {
logger.warn('Clamped embedding batch concurrency to the worker safety range', {
configured: configuredEmbeddingConcurrency,
effective: MAX_CONCURRENT_BATCHES,
maximum: MAX_ALLOWED_CONCURRENT_BATCHES,
})
}
const EMBEDDING_REQUEST_TIMEOUT_MS = 60_000
/**
@@ -63,24 +95,47 @@ const EMBEDDING_REQUEST_TIMEOUT_MS = 60_000
*/
const BATCH_TOKEN_TARGET = 8192
/**
* Hard ceiling on one successful embedding response.
*
* Gemini's documented 100-item request cap at the catalog's largest 3,072
* dimensions fits comfortably inside 16 MiB, including a conservative JSON
* representation allowance. Larger OpenAI-style batches are split below from
* their expected vector width, so the guard rejects malformed provider output
* rather than valid catalog traffic.
*/
export const MAX_EMBEDDING_SUCCESS_RESPONSE_BYTES = 16 * 1024 * 1024
/** Bounds vectors retained across batches, aligned with the app's 100 MiB response ceiling. */
export const MAX_EMBEDDING_AGGREGATE_RESPONSE_BYTES = 100 * 1024 * 1024
/** Leaves room for the provider envelope, usage metadata, indices, and delimiters. */
const EMBEDDING_RESPONSE_ENVELOPE_RESERVE_BYTES = 64 * 1024
/**
* JSON may render a finite double with more characters than its in-memory
* representation. Thirty-two bytes per coordinate is deliberately conservative
* for the number, comma, and surrounding array syntax.
*/
const EMBEDDING_RESPONSE_BYTES_PER_DIMENSION = 32
const EMBEDDING_RESPONSE_BYTES_PER_ITEM = 128
/** Retries after the initial attempt, per embedding request. */
export const EMBEDDING_MAX_RETRIES = 5
/** Ceiling on a single wait between embedding attempts, including a provider-stated one. */
/** Ceiling on exponential backoff when the provider supplies no retry delay. */
export const EMBEDDING_MAX_RETRY_DELAY_MS = 30_000
/**
* Longest a request can stay in the retry loop: every attempt waits at most the
* ceiling, so the budget is what the attempts span in total. A provider window
* that reopens inside this is still reachable even though each individual wait
* is clamped below it.
* Longest a request can stay in the retry loop. An admitted provider-stated wait
* is honored in full when it fits inside this deadline.
*/
const EMBEDDING_RETRY_BUDGET_MS = EMBEDDING_MAX_RETRIES * EMBEDDING_MAX_RETRY_DELAY_MS
export class EmbeddingAPIError extends Error {
public status: number
/** Rejected for an exhausted balance rather than a recoverable rate. Both are 429. */
/** Rejected for an exhausted balance rather than a recoverable rate limit. */
public quotaExhausted?: boolean
/**
@@ -96,6 +151,59 @@ export class EmbeddingAPIError extends Error {
}
}
class EmbeddingResponseValidationError extends EmbeddingAPIError {
constructor(message: string) {
super(`Embedding API returned an invalid success response: ${message}`, 502)
this.name = 'EmbeddingResponseValidationError'
}
}
export class EmbeddingOutputLimitError extends Error {
constructor(itemCount: number, dimensions: number, estimatedBytes: number) {
super(
`Embedding output for ${itemCount} inputs at ${dimensions} dimensions is estimated at ${estimatedBytes} bytes, exceeding the safe aggregate limit of ${MAX_EMBEDDING_AGGREGATE_RESPONSE_BYTES} bytes`
)
this.name = 'EmbeddingOutputLimitError'
}
}
export const EMBEDDING_QUOTA_EXHAUSTED_MESSAGE =
'The embedding provider has exhausted its available quota. Add credit or replace the credential before retrying.'
/**
* A provider credential has no remaining credit. This remains transient across
* providers so a configured fallback can run, but it is terminal for the
* credential and for a Trigger task after every fallback is exhausted.
*/
export class EmbeddingQuotaExhaustedError extends EmbeddingAPIError {
public readonly providerId: EmbeddingProviderKind
constructor(providerId: EmbeddingProviderKind, cause?: unknown) {
const status = cause instanceof EmbeddingAPIError ? cause.status : 429
super(
`The ${providerId} embedding credential has exhausted its available quota. Add credit or replace the credential before retrying.`,
status
)
this.name = 'EmbeddingQuotaExhaustedError'
this.providerId = providerId
this.quotaExhausted = true
this.cause = cause
}
}
/**
* True only when the overall embedding operation failed because every provider
* it attempted had exhausted credit. A mixed fallback failure must retain task
* retries because another provider may merely be temporarily unavailable.
*/
export function isEmbeddingQuotaExhaustion(error: unknown): boolean {
if (error instanceof EmbeddingAPIError) return error.quotaExhausted === true
if (error instanceof AggregateError) {
return error.errors.length > 0 && error.errors.every(isEmbeddingQuotaExhaustion)
}
return false
}
/**
* True when a rejection body reports an exhausted balance rather than a rate
* limit. OpenAI returns 429 for both, but only a rate limit reopens: a spent
@@ -116,15 +224,24 @@ function isQuotaExhaustionBody(errorText: string): boolean {
}
}
/** Reads a bounded provider body only for internal quota classification. */
async function readEmbeddingErrorBody(response: Response): Promise<string> {
try {
return await readResponseTextWithLimit(response, {
maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES,
label: 'Embedding API error response',
})
} catch {
return ''
}
}
/**
* True when the provider's stated wait outlasts the entire retry budget.
*
* The comparison is against {@link EMBEDDING_RETRY_BUDGET_MS} rather than the
* per-attempt ceiling, because a window longer than one wait is still reachable:
* the attempts are clamped individually but accumulate, so a 35s window reopens
* before the second one lands. Only a window outlasting every attempt is
* genuinely unreachable, and retrying into it spends the budget waiting on
* something that cannot happen.
* The retry layer honors a provider-stated wait in full only when it fits inside
* the remaining operation deadline. A wait longer than the entire embedding
* budget can therefore never be admitted.
*
* The error stays transient — it just is not worth retrying here — so refusing
* the retry surfaces it immediately and the fallback chain, which classifies
@@ -148,12 +265,14 @@ function statedWaitOutlastsBudget(error: unknown): boolean {
*/
function isWorthRetrying(error: unknown): boolean {
if (!isTransientEmbeddingError(error)) return false
if (error instanceof EmbeddingResponseValidationError) return false
if (error instanceof EmbeddingAPIError && error.quotaExhausted) return false
return !statedWaitOutlastsBudget(error)
}
export function isTransientEmbeddingError(error: unknown): boolean {
if (error instanceof EmbeddingAPIError) {
if (error.quotaExhausted) return true
return error.status === 429 || error.status >= 500
}
if (error instanceof Error && error.name === 'AbortError') return true
@@ -163,6 +282,8 @@ export function isTransientEmbeddingError(error: unknown): boolean {
interface ResolvedProvider {
adapter: EmbeddingProviderAdapter
info: EmbeddingModelInfo
providerId: EmbeddingProviderKind
quotaCircuitIdentity: EmbeddingQuotaCircuitIdentity
/** Model name as sent to the provider (an Azure deployment name when Azure is active). */
modelName: string
/** Dimensionality the request will produce, for reporting and billing. */
@@ -207,6 +328,8 @@ async function resolveProvider(model: string, options: EmbedOptions): Promise<Re
nativeDimensions: info.nativeDimensions,
}),
info,
providerId: 'openrouter',
quotaCircuitIdentity: createEmbeddingQuotaCircuitIdentity('openrouter', options.apiKey),
modelName: model,
dimensions,
isBYOK: true,
@@ -225,6 +348,8 @@ async function resolveProvider(model: string, options: EmbedOptions): Promise<Re
apiVersion: azure.apiVersion,
}),
info,
providerId: 'azure-openai',
quotaCircuitIdentity: createEmbeddingQuotaCircuitIdentity('azure-openai', azure.apiKey),
modelName: azure.deployment,
dimensions,
isBYOK: false,
@@ -243,28 +368,80 @@ async function resolveProvider(model: string, options: EmbedOptions): Promise<Re
nativeDimensions: info.nativeDimensions,
}),
info,
providerId: info.provider,
quotaCircuitIdentity: createEmbeddingQuotaCircuitIdentity(info.provider, apiKey),
modelName: model,
dimensions,
isBYOK,
}
}
function validateEmbeddingBatch(
value: unknown,
expectedCount: number,
expectedDimensions: number | undefined
): { embeddings: number[][]; dimensions: number } {
if (!Array.isArray(value)) {
throw new EmbeddingResponseValidationError('the vector payload is not an array')
}
if (value.length !== expectedCount) {
throw new EmbeddingResponseValidationError(
`returned ${value.length} embeddings for ${expectedCount} inputs`
)
}
let resolvedDimensions = expectedDimensions
for (let index = 0; index < value.length; index++) {
const vector = value[index]
if (!Array.isArray(vector) || vector.length === 0) {
throw new EmbeddingResponseValidationError(`vector ${index} is empty or not an array`)
}
if (
vector.some((coordinate) => typeof coordinate !== 'number' || !Number.isFinite(coordinate))
) {
throw new EmbeddingResponseValidationError(
`vector ${index} contains a non-numeric or non-finite coordinate`
)
}
resolvedDimensions ??= vector.length
if (vector.length !== resolvedDimensions) {
const qualifier = expectedDimensions === undefined ? 'inconsistent' : 'unexpected'
throw new EmbeddingResponseValidationError(
`vector ${index} has ${vector.length} ${qualifier} dimensions; expected ${resolvedDimensions}`
)
}
}
if (resolvedDimensions === undefined) {
throw new EmbeddingResponseValidationError('the response did not contain any vectors')
}
return { embeddings: value as number[][], dimensions: resolvedDimensions }
}
/** `inputs` are already projected and batched by the embedding orchestrator. */
async function callEmbeddingAPI(
inputs: string[],
adapter: EmbeddingProviderAdapter,
tokenizerProvider: string,
taskType: EmbeddingTaskType,
providerId: EmbeddingProviderKind,
quotaCircuitIdentity: EmbeddingQuotaCircuitIdentity,
/**
* The caller's explicit reduction, or undefined when none was requested. Kept
* distinct from `provider.dimensions` because a model without Matryoshka
* support rejects the parameter outright — sending it populated with the
* native size is a 400, not a no-op.
*/
requestedDimensions: number | undefined
): Promise<{ embeddings: number[][]; totalTokens: number }> {
requestedDimensions: number | undefined,
expectedDimensions: number | undefined
): Promise<{ embeddings: number[][]; totalTokens: number; dimensions: number }> {
return retryWithExponentialBackoff(
async () => {
if (await isEmbeddingQuotaCircuitOpen(quotaCircuitIdentity)) {
throw new EmbeddingQuotaExhaustedError(providerId)
}
const request = adapter.buildRequest({
inputs,
taskType,
@@ -282,12 +459,19 @@ async function callEmbeddingAPI(
}).finally(() => clearTimeout(timeout))
if (!response.ok) {
const errorText = await response.text()
const classificationBody = await readEmbeddingErrorBody(response)
const error = new EmbeddingAPIError(
`Embedding API failed: ${response.status} ${response.statusText} - ${errorText}`,
`Embedding API failed: ${response.status}`,
response.status
)
error.quotaExhausted = isQuotaExhaustionBody(errorText)
error.quotaExhausted =
isQuotaExhaustionBody(classificationBody) ||
(providerId === 'openrouter' && response.status === 402)
if (error.quotaExhausted) {
await openEmbeddingQuotaCircuit(quotaCircuitIdentity)
throw new EmbeddingQuotaExhaustedError(providerId, error)
}
/**
* Carry the provider's own answer to "when may I retry" onto the error,
@@ -307,8 +491,21 @@ async function callEmbeddingAPI(
throw error
}
const json = await response.json()
const embeddings = request.parse(json)
const json = await readResponseJsonWithLimit(response, {
maxBytes: MAX_EMBEDDING_SUCCESS_RESPONSE_BYTES,
label: 'Embedding API success response',
})
let parsedEmbeddings: unknown
try {
parsedEmbeddings = request.parse(json)
} catch {
throw new EmbeddingResponseValidationError('the vector payload could not be parsed')
}
const { embeddings, dimensions } = validateEmbeddingBatch(
parsedEmbeddings,
inputs.length,
expectedDimensions
)
/**
* Fallback for a response that carries no usage block. Estimated with the
* provider's own tokenizer, which is approximate for every non-OpenAI
@@ -318,15 +515,13 @@ async function callEmbeddingAPI(
request.parseTokens?.(json) ??
inputs.reduce((sum, text) => sum + estimateTokenCount(text, tokenizerProvider).count, 0)
return { embeddings, totalTokens }
return { embeddings, totalTokens, dimensions }
},
{
/**
* Sized against a rate-limit window rather than a transient blip. The
* provider states its reset in tens of seconds, and the loop clamps that
* stated wait to `maxDelayMs` — at the previous 10s ceiling every attempt
* fired before the window reopened, so the budget was spent without one
* retry landing in the reopened window.
* provider states its reset in tens of seconds, and the loop honors that
* wait when it fits inside the operation budget.
*
* Bounded so a fully saturated provider cannot outlive the task: five
* attempts at the ceiling is well inside `KB_CONFIG_MAX_DURATION`, and
@@ -335,6 +530,7 @@ async function callEmbeddingAPI(
maxRetries: EMBEDDING_MAX_RETRIES,
initialDelayMs: 1000,
maxDelayMs: EMBEDDING_MAX_RETRY_DELAY_MS,
retryBudgetMs: EMBEDDING_RETRY_BUDGET_MS,
retryCondition: isWorthRetrying,
}
)
@@ -408,11 +604,13 @@ async function embedWithProvider(
requestedDimensions: number | undefined,
provider: ResolvedProvider
): Promise<EmbedResult> {
assertEmbeddingAggregateResponseWithinLimit(boundedInputs.length, provider.dimensions)
const batches = createEmbeddingBatches(
boundedInputs,
model,
getEmbeddingInputLimits(provider.info),
provider.adapter.maxItemsPerRequest
provider.adapter.maxItemsPerRequest,
provider.dimensions
)
const batchResults = await mapWithConcurrency(
@@ -425,10 +623,18 @@ async function embedWithProvider(
provider.adapter,
provider.info.tokenizerProvider,
taskType,
requestedDimensions
provider.providerId,
provider.quotaCircuitIdentity,
requestedDimensions,
provider.dimensions
)
} catch (error) {
logger.error(`Failed to generate embeddings for batch ${i + 1}/${batches.length}:`, error)
const message = `Failed to generate embeddings for batch ${i + 1}/${batches.length}:`
if (isEmbeddingQuotaExhaustion(error)) {
logger.warn(message, { providerId: provider.providerId, quotaExhausted: true })
} else {
logger.error(message, error)
}
throw error
}
}
@@ -451,7 +657,8 @@ function createEmbeddingBatches(
boundedInputs: string[],
model: string,
limits: Pick<EmbeddingInputLimits, 'maxInputTokens' | 'maxTokensPerRequest'>,
itemLimit: number | undefined
itemLimit: number | undefined,
dimensions: number | undefined
): string[][] {
const ceiling = limits.maxInputTokens
@@ -475,21 +682,65 @@ function createEmbeddingBatches(
)
const tokenBatches = batchByTokenLimit(boundedInputs, requestBudget, model)
return itemLimit ? tokenBatches.flatMap((batch) => chunkArray(batch, itemLimit)) : tokenBatches
const responseItemLimit = dimensions
? Math.max(
1,
Math.floor(
(MAX_EMBEDDING_SUCCESS_RESPONSE_BYTES - EMBEDDING_RESPONSE_ENVELOPE_RESERVE_BYTES) /
(dimensions * EMBEDDING_RESPONSE_BYTES_PER_DIMENSION +
EMBEDDING_RESPONSE_BYTES_PER_ITEM)
)
)
: undefined
const effectiveItemLimit =
itemLimit && responseItemLimit
? Math.min(itemLimit, responseItemLimit)
: (itemLimit ?? responseItemLimit)
return effectiveItemLimit
? tokenBatches.flatMap((batch) => chunkArray(batch, effectiveItemLimit))
: tokenBatches
}
function assertEmbeddingAggregateResponseWithinLimit(itemCount: number, dimensions: number): void {
if (itemCount <= getEmbeddingAggregateItemLimit(dimensions)) return
const estimatedBytes =
EMBEDDING_RESPONSE_ENVELOPE_RESERVE_BYTES +
itemCount *
(dimensions * EMBEDDING_RESPONSE_BYTES_PER_DIMENSION + EMBEDDING_RESPONSE_BYTES_PER_ITEM)
throw new EmbeddingOutputLimitError(itemCount, dimensions, estimatedBytes)
}
export function getEmbeddingAggregateItemLimit(dimensions: number): number {
if (!Number.isInteger(dimensions) || dimensions <= 0) {
throw new Error('Embedding dimensions must be a positive integer')
}
return Math.floor(
(MAX_EMBEDDING_AGGREGATE_RESPONSE_BYTES - EMBEDDING_RESPONSE_ENVELOPE_RESERVE_BYTES) /
(dimensions * EMBEDDING_RESPONSE_BYTES_PER_DIMENSION + EMBEDDING_RESPONSE_BYTES_PER_ITEM)
)
}
function combineEmbeddingBatches(
batchResults: readonly { embeddings: number[][]; totalTokens: number }[]
): { embeddings: number[][]; totalTokens: number } {
batchResults: readonly { embeddings: number[][]; totalTokens: number; dimensions: number }[]
): { embeddings: number[][]; totalTokens: number; dimensions: number | undefined } {
const embeddings: number[][] = []
let totalTokens = 0
let dimensions: number | undefined
for (const batch of batchResults) {
dimensions ??= batch.dimensions
if (batch.dimensions !== dimensions) {
throw new EmbeddingResponseValidationError(
`concurrent batches returned inconsistent dimensions (${dimensions} and ${batch.dimensions})`
)
}
for (const vector of batch.embeddings) {
embeddings.push(vector)
}
totalTokens += batch.totalTokens
}
return { embeddings, totalTokens }
return { embeddings, totalTokens, dimensions }
}
/**
@@ -532,26 +783,59 @@ export async function embedOpenRouter(
apiKey: options.apiKey,
nativeDimensions: options.dimensions ?? 0,
})
const batches = createEmbeddingBatches(boundedInputs, model, limits, adapter.maxItemsPerRequest)
const batchResults = await mapWithConcurrency(batches, MAX_CONCURRENT_BATCHES, async (batch) =>
callEmbeddingAPI(batch, adapter, limits.tokenizerProvider, 'document', options.dimensions)
)
const quotaCircuitIdentity = createEmbeddingQuotaCircuitIdentity('openrouter', options.apiKey)
const callOpenRouterBatch = (
batch: string[],
expectedDimensions: number | undefined
): Promise<{ embeddings: number[][]; totalTokens: number; dimensions: number }> =>
callEmbeddingAPI(
batch,
adapter,
limits.tokenizerProvider,
'document',
'openrouter',
quotaCircuitIdentity,
options.dimensions,
expectedDimensions
)
let batchResults: { embeddings: number[][]; totalTokens: number; dimensions: number }[]
if (options.dimensions === undefined) {
const firstInput = boundedInputs[0]
if (firstInput === undefined) {
throw new EmbeddingResponseValidationError('the response did not contain any vectors')
}
const firstResult = await callOpenRouterBatch([firstInput], undefined)
assertEmbeddingAggregateResponseWithinLimit(boundedInputs.length, firstResult.dimensions)
const batches = createEmbeddingBatches(
boundedInputs.slice(1),
model,
limits,
adapter.maxItemsPerRequest,
firstResult.dimensions
)
const remainingResults = await mapWithConcurrency(batches, MAX_CONCURRENT_BATCHES, (batch) =>
callOpenRouterBatch(batch, firstResult.dimensions)
)
batchResults = [firstResult, ...remainingResults]
} else {
assertEmbeddingAggregateResponseWithinLimit(boundedInputs.length, options.dimensions)
const batches = createEmbeddingBatches(
boundedInputs,
model,
limits,
adapter.maxItemsPerRequest,
options.dimensions
)
batchResults = await mapWithConcurrency(batches, MAX_CONCURRENT_BATCHES, (batch) =>
callOpenRouterBatch(batch, options.dimensions)
)
}
const result = combineEmbeddingBatches(batchResults)
if (result.embeddings.length !== boundedInputs.length) {
throw new Error(
`OpenRouter returned ${result.embeddings.length} embeddings for ${boundedInputs.length} inputs`
)
}
const dimensions = result.embeddings[0]?.length
if (!dimensions) throw new Error('OpenRouter returned an empty embedding vector')
if (result.embeddings.some((embedding) => embedding.length !== dimensions)) {
throw new Error('OpenRouter returned embedding vectors with inconsistent dimensions')
}
if (options.dimensions !== undefined && dimensions !== options.dimensions) {
throw new Error(
`OpenRouter returned ${dimensions} dimensions instead of the requested ${options.dimensions}`
)
const dimensions = result.dimensions
if (dimensions === undefined) {
throw new EmbeddingResponseValidationError('the response did not contain any vectors')
}
return {
@@ -585,6 +869,7 @@ export async function embedKnowledgeForDeployment(
}
const dimensions = resolveDimensions(info, options.dimensions)
assertEmbeddingAggregateResponseWithinLimit(texts.length, dimensions)
const taskType = options.taskType ?? 'document'
const boundedInputs = prepareEmbeddingInputs(
texts,
@@ -608,6 +893,8 @@ export async function embedKnowledgeForDeployment(
apiVersion: azure.apiVersion,
}),
info,
providerId: 'azure-openai',
quotaCircuitIdentity: createEmbeddingQuotaCircuitIdentity('azure-openai', azure.apiKey),
modelName: azure.deployment,
dimensions,
isBYOK: false,
@@ -622,6 +909,8 @@ export async function embedKnowledgeForDeployment(
nativeDimensions: info.nativeDimensions,
}),
info,
providerId: 'openai',
quotaCircuitIdentity: createEmbeddingQuotaCircuitIdentity('openai', apiKey),
modelName: model,
dimensions,
isBYOK: Boolean(workspaceKey),
@@ -636,6 +925,11 @@ export async function embedKnowledgeForDeployment(
nativeDimensions: info.nativeDimensions,
}),
info,
providerId: 'openrouter',
quotaCircuitIdentity: createEmbeddingQuotaCircuitIdentity(
'openrouter',
env.OPENROUTER_API_KEY
),
modelName: model,
dimensions,
isBYOK: false,
@@ -649,10 +943,12 @@ export async function embedKnowledgeForDeployment(
factories,
shouldFallback: isTransientEmbeddingError,
onFailure(providerId, error) {
logger.warn('Knowledge embedding provider failed; continuing fallback chain', {
providerId,
error,
})
logger.warn(
'Knowledge embedding provider failed; continuing fallback chain',
isEmbeddingQuotaExhaustion(error)
? { providerId, quotaExhausted: true }
: { providerId, error }
)
},
})
@@ -663,7 +959,8 @@ export async function embedKnowledgeForDeployment(
boundedInputs,
model,
info,
itemLimits.length > 0 ? Math.min(...itemLimits) : undefined
itemLimits.length > 0 ? Math.min(...itemLimits) : undefined,
dimensions
)
const batchResults = await mapWithConcurrency(
batches,
@@ -676,12 +973,20 @@ export async function embedKnowledgeForDeployment(
provider.adapter,
provider.info.tokenizerProvider,
taskType,
options.dimensions
provider.providerId,
provider.quotaCircuitIdentity,
options.dimensions,
provider.dimensions
)),
provider,
}))
} catch (error) {
logger.error(`Failed to generate embeddings for batch ${i + 1}/${batches.length}:`, error)
const message = `Failed to generate embeddings for batch ${i + 1}/${batches.length}:`
if (isEmbeddingQuotaExhaustion(error)) {
logger.warn(message, { quotaExhausted: true })
} else {
logger.error(message, error)
}
throw error
}
}
+9 -1
View File
@@ -9,7 +9,15 @@ export {
findEmbeddingModelInfo,
resolveDimensions,
} from '@/lib/embeddings/catalog'
export { embed, embedKnowledge, embedOpenRouter } from '@/lib/embeddings/client'
export {
EMBEDDING_QUOTA_EXHAUSTED_MESSAGE,
EmbeddingOutputLimitError,
embed,
embedKnowledge,
embedOpenRouter,
getEmbeddingAggregateItemLimit,
isEmbeddingQuotaExhaustion,
} from '@/lib/embeddings/client'
export { DEFAULT_OPENROUTER_EMBEDDING_MODEL } from '@/lib/embeddings/openrouter-models'
export type {
EmbeddingTaskType,
@@ -0,0 +1,130 @@
/**
* @vitest-environment node
*/
import { sha256Hex } from '@sim/security/hash'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetRedisClient } = vi.hoisted(() => ({
mockGetRedisClient: vi.fn(),
}))
vi.mock('@/lib/core/config/redis', () => ({
getRedisClient: mockGetRedisClient,
}))
import {
createEmbeddingQuotaCircuitIdentity,
EMBEDDING_QUOTA_CIRCUIT_TTL_MS,
isEmbeddingQuotaCircuitOpen,
openEmbeddingQuotaCircuit,
resetEmbeddingQuotaCircuitsForTesting,
} from '@/lib/embeddings/quota-circuit'
describe('embedding quota circuit', () => {
const values = new Map<string, string>()
const redis = {
get: vi.fn(async (key: string) => values.get(key) ?? null),
eval: vi.fn(async (_script: string, _keyCount: number, key: string, value: string) => {
const current = Number(values.get(key))
const proposed = Number(value)
const expiry = Number.isFinite(current) ? Math.max(current, proposed) : proposed
values.set(key, String(expiry))
return expiry
}),
}
beforeEach(() => {
vi.clearAllMocks()
values.clear()
resetEmbeddingQuotaCircuitsForTesting()
mockGetRedisClient.mockReturnValue(redis)
})
afterEach(() => {
resetEmbeddingQuotaCircuitsForTesting()
})
it('fingerprints credentials without retaining the secret', () => {
const identity = createEmbeddingQuotaCircuitIdentity('openai', 'sk-secret')
expect(identity).toEqual({
providerId: 'openai',
credentialFingerprint: sha256Hex('sk-secret'),
})
expect(JSON.stringify(identity)).not.toContain('sk-secret')
})
it('shares an exhausted credential across workers without extending its expiry', async () => {
const identity = createEmbeddingQuotaCircuitIdentity('openai', 'sk-shared')
const openedAt = 1_000_000
await openEmbeddingQuotaCircuit(identity, openedAt)
resetEmbeddingQuotaCircuitsForTesting()
expect(await isEmbeddingQuotaCircuitOpen(identity, openedAt + 1)).toBe(true)
expect(redis.eval).toHaveBeenCalledWith(
expect.stringContaining("'PXAT'"),
1,
expect.stringContaining('embedding-quota-circuit:openai:'),
String(openedAt + EMBEDDING_QUOTA_CIRCUIT_TTL_MS)
)
expect(
await isEmbeddingQuotaCircuitOpen(identity, openedAt + EMBEDDING_QUOTA_CIRCUIT_TTL_MS + 1)
).toBe(false)
})
it('does not shorten an existing expiry when an older observation completes later', async () => {
const identity = createEmbeddingQuotaCircuitIdentity('openai', 'sk-concurrent')
const olderObservation = 1_000_000
const newerObservation = olderObservation + 60_000
await openEmbeddingQuotaCircuit(identity, newerObservation)
await openEmbeddingQuotaCircuit(identity, olderObservation)
resetEmbeddingQuotaCircuitsForTesting()
expect(
await isEmbeddingQuotaCircuitOpen(
identity,
olderObservation + EMBEDDING_QUOTA_CIRCUIT_TTL_MS + 1
)
).toBe(true)
expect([...values.values()]).toEqual([
String(newerObservation + EMBEDDING_QUOTA_CIRCUIT_TTL_MS),
])
const script = redis.eval.mock.calls.at(-1)?.[0]
expect(script).toMatch(/if\s+current\s+and\s+current\s*>\s*proposed\s+then/)
})
it('isolates different providers and credentials', async () => {
const exhausted = createEmbeddingQuotaCircuitIdentity('openai', 'sk-exhausted')
await openEmbeddingQuotaCircuit(exhausted, 1_000_000)
expect(await isEmbeddingQuotaCircuitOpen(exhausted, 1_000_001)).toBe(true)
expect(
await isEmbeddingQuotaCircuitOpen(
createEmbeddingQuotaCircuitIdentity('openai', 'sk-healthy'),
1_000_001
)
).toBe(false)
expect(
await isEmbeddingQuotaCircuitOpen(
createEmbeddingQuotaCircuitIdentity('openrouter', 'sk-exhausted'),
1_000_001
)
).toBe(false)
})
it('fails open when Redis is unavailable', async () => {
mockGetRedisClient.mockImplementation(() => {
throw new Error('Redis unavailable')
})
expect(
await isEmbeddingQuotaCircuitOpen(
createEmbeddingQuotaCircuitIdentity('openai', 'sk-test'),
1_000_000
)
).toBe(false)
})
})
+136
View File
@@ -0,0 +1,136 @@
import { createLogger } from '@sim/logger'
import { sha256Hex } from '@sim/security/hash'
import { getRedisClient } from '@/lib/core/config/redis'
import type { EmbeddingProviderKind } from '@/lib/embeddings/types'
const logger = createLogger('EmbeddingQuotaCircuit')
/**
* A billing change can take a couple of minutes to reach a provider. Keeping the
* circuit open for five minutes absorbs the document queue that observed the
* same exhausted credential while still probing again without operator action.
*/
export const EMBEDDING_QUOTA_CIRCUIT_TTL_MS = 5 * 60 * 1000
const REDIS_KEY_PREFIX = 'embedding-quota-circuit:'
const MAX_LOCAL_CIRCUITS = 1024
const OPEN_QUOTA_CIRCUIT_SCRIPT = `
local current = tonumber(redis.call('GET', KEYS[1]))
local proposed = tonumber(ARGV[1])
local expiry = proposed
if current and current > proposed then
expiry = current
end
redis.call('SET', KEYS[1], tostring(expiry), 'PXAT', expiry)
return expiry
`
export interface EmbeddingQuotaCircuitIdentity {
readonly providerId: EmbeddingProviderKind
/** SHA-256 fingerprint; the provider credential itself never reaches cache or logs. */
readonly credentialFingerprint: string
}
const localCircuits = new Map<string, number>()
function writeLocalCircuit(key: string, expiresAt: number, now: number): void {
for (const [candidateKey, candidateExpiry] of localCircuits) {
if (candidateExpiry <= now) localCircuits.delete(candidateKey)
}
const currentExpiry = localCircuits.get(key)
if (currentExpiry !== undefined) {
localCircuits.set(key, Math.max(currentExpiry, expiresAt))
return
}
while (localCircuits.size >= MAX_LOCAL_CIRCUITS) {
const oldestKey = localCircuits.keys().next().value
if (oldestKey === undefined) break
localCircuits.delete(oldestKey)
}
localCircuits.set(key, expiresAt)
}
function circuitKey(identity: EmbeddingQuotaCircuitIdentity): string {
return `${identity.providerId}:${identity.credentialFingerprint}`
}
function redisKey(identity: EmbeddingQuotaCircuitIdentity): string {
return `${REDIS_KEY_PREFIX}${circuitKey(identity)}`
}
/** Isolates quota state to the exact provider credential without retaining the secret. */
export function createEmbeddingQuotaCircuitIdentity(
providerId: EmbeddingProviderKind,
apiKey: string
): EmbeddingQuotaCircuitIdentity {
return {
providerId,
credentialFingerprint: sha256Hex(apiKey),
}
}
function readLocalCircuit(identity: EmbeddingQuotaCircuitIdentity, now: number): boolean {
const key = circuitKey(identity)
const expiresAt = localCircuits.get(key)
if (expiresAt === undefined) return false
if (expiresAt > now) return true
localCircuits.delete(key)
return false
}
/**
* Returns whether another worker has already observed exhausted credit for this
* credential. Cache failures deliberately fail open: quota protection must not
* turn a Redis outage into a knowledge-search outage.
*/
export async function isEmbeddingQuotaCircuitOpen(
identity: EmbeddingQuotaCircuitIdentity,
now = Date.now()
): Promise<boolean> {
if (readLocalCircuit(identity, now)) return true
try {
const redis = getRedisClient()
if (!redis) return false
const storedExpiry = await redis.get(redisKey(identity))
if (!storedExpiry) return false
const expiresAt = Number(storedExpiry)
if (!Number.isFinite(expiresAt) || expiresAt <= now) return false
writeLocalCircuit(circuitKey(identity), expiresAt, now)
return true
} catch (error) {
logger.warn('Failed to read embedding quota circuit; continuing with provider request', {
providerId: identity.providerId,
error,
})
return false
}
}
/**
* Shares a provider-declared exhausted-credit result with every worker using
* the same credential. The absolute expiry keeps a late reader from extending
* the circuit by another full TTL.
*/
export async function openEmbeddingQuotaCircuit(
identity: EmbeddingQuotaCircuitIdentity,
now = Date.now()
): Promise<void> {
const expiresAt = now + EMBEDDING_QUOTA_CIRCUIT_TTL_MS
writeLocalCircuit(circuitKey(identity), expiresAt, now)
try {
const redis = getRedisClient()
if (!redis) return
await redis.eval(OPEN_QUOTA_CIRCUIT_SCRIPT, 1, redisKey(identity), String(expiresAt))
} catch (error) {
logger.warn('Failed to share embedding quota circuit; process-local circuit remains active', {
providerId: identity.providerId,
error,
})
}
}
export function resetEmbeddingQuotaCircuitsForTesting(): void {
localCircuits.clear()
}
+8 -2
View File
@@ -2,6 +2,7 @@ import { createReadStream, existsSync } from 'fs'
import { Readable } from 'stream'
import { createLogger } from '@sim/logger'
import { type Options, parse } from 'csv-parse'
import { FileParserError } from '@/lib/file-parsers/errors'
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
import { sanitizeTextForUTF8, truncationNotice } from '@/lib/file-parsers/utils'
@@ -111,13 +112,18 @@ export class CsvParser implements FileParser {
if (errorCount >= CONFIG.MAX_ERRORS) {
aborted = true
parser.destroy()
reject(new Error(`Too many errors (${errorCount}). File may be corrupted.`))
reject(
new FileParserError(
'invalid_format',
`Too many errors (${errorCount}). File may be corrupted.`
)
)
}
})
parser.on('error', (err: Error) => {
logger.error('CSV parser error:', err)
reject(new Error(`CSV parsing failed: ${err.message}`))
reject(new FileParserError('invalid_format', `CSV parsing failed: ${err.message}`, err))
})
parser.on('end', () => {
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, it, vi } from 'vitest'
import { decodeDataUriWithinLimit } from '@/lib/file-parsers/data-uri'
import { FileParserError } from '@/lib/file-parsers/errors'
describe('decodeDataUriWithinLimit', () => {
it('preserves commas after the first delimiter', () => {
const decoded = decodeDataUriWithinLimit('data:text/plain,alpha,beta,gamma', 100)
expect(decoded.buffer.toString('utf8')).toBe('alpha,beta,gamma')
expect(decoded.mediaType).toBe('text/plain')
})
it('rejects malformed base64 instead of silently decoding a prefix', () => {
expect(() => decodeDataUriWithinLimit('data:text/plain;base64,@@@=', 100)).toThrowError(
expect.objectContaining({ code: 'invalid_format' })
)
})
it('accepts valid unpadded base64 without weakening alphabet validation', () => {
const decoded = decodeDataUriWithinLimit('data:text/plain;base64,aGk', 2)
expect(decoded.buffer.toString('utf8')).toBe('hi')
})
it('accepts percent-escaped base64 bytes before validating padding', () => {
const decoded = decodeDataUriWithinLimit('data:text/plain;base64,aGk%3D', 2)
expect(decoded.buffer.toString('utf8')).toBe('hi')
})
it('rejects malformed percent escapes in a base64 payload', () => {
expect(() => decodeDataUriWithinLimit('data:text/plain;base64,aGk%ZZ', 2)).toThrowError(
expect.objectContaining({ code: 'invalid_format' })
)
})
it('accepts a base64 payload at the encoded boundary, including whitespace', () => {
const payload = Buffer.from('12345').toString('base64')
const decoded = decodeDataUriWithinLimit(
`data:application/octet-stream;base64,${payload.slice(0, 4)}\n${payload.slice(4)}`,
5
)
expect(decoded.buffer.toString('utf8')).toBe('12345')
expect(decoded.mediaType).toBe('application/octet-stream')
})
it('preserves percent-encoded binary octets that are not standalone UTF-8', () => {
const decoded = decodeDataUriWithinLimit('data:application/octet-stream,%FF%00A', 3)
expect([...decoded.buffer]).toEqual([0xff, 0x00, 0x41])
})
it('rejects the encoded representation before decoding', () => {
const error = (() => {
try {
decodeDataUriWithinLimit(`data:text/plain,${'x'.repeat(50)}`, 10)
} catch (caught) {
return caught
}
})()
expect(error).toBeInstanceOf(FileParserError)
expect(error).toMatchObject({ code: 'complexity_limit' })
})
it('rejects an oversized descriptor before splitting its parameters', () => {
const descriptor = `text/plain;${'x;'.repeat(4096)}`
expect(() => decodeDataUriWithinLimit(`data:${descriptor},ok`, 10)).toThrowError(
expect.objectContaining({ code: 'complexity_limit' })
)
})
it('sizes non-base64 decode storage from the input instead of the file cap', () => {
const allocation = vi.spyOn(Buffer, 'allocUnsafe')
try {
const decoded = decodeDataUriWithinLimit('data:text/plain,hi', 100 * 1024 * 1024)
expect(decoded.buffer.toString('utf8')).toBe('hi')
expect(allocation).toHaveBeenCalledWith(7)
} finally {
allocation.mockRestore()
}
})
it('rejects decoded bytes beyond the cap', () => {
const payload = Buffer.from('eleven-bytes').toString('base64')
expect(() => decodeDataUriWithinLimit(`data:text/plain;base64,${payload}`, 10)).toThrowError(
expect.objectContaining({ code: 'complexity_limit' })
)
})
it('rejects a near-four-times base64 payload before decoding', () => {
const maxBytes = 1024
const payload = 'A'.repeat(maxBytes * 4)
expect(() =>
decodeDataUriWithinLimit(`data:application/octet-stream;base64,${payload}`, maxBytes)
).toThrowError(expect.objectContaining({ code: 'complexity_limit' }))
})
it('bounds base64 transport whitespace before compacting the payload', () => {
const maxBytes = 1024
const encodedPayload = Buffer.alloc(maxBytes).toString('base64')
const whitespaceBomb = `${encodedPayload}${' '.repeat(2048)}`
expect(() =>
decodeDataUriWithinLimit(`data:application/octet-stream;base64,${whitespaceBomb}`, maxBytes)
).toThrowError(expect.objectContaining({ code: 'complexity_limit' }))
})
})
+176
View File
@@ -0,0 +1,176 @@
import { FileParserError } from '@/lib/file-parsers/errors'
export interface DecodedDataUri {
buffer: Buffer
mediaType: string | null
}
const MAX_SAFE_BASE64_INPUT_BYTES = Math.floor(Number.MAX_SAFE_INTEGER / 4) * 3
const MAX_SAFE_PERCENT_INPUT_BYTES = Math.floor((Number.MAX_SAFE_INTEGER - 4) / 4)
const MAX_BASE64_WHITESPACE_BYTES = 4 * 1024 * 1024
const MAX_DATA_URI_DESCRIPTOR_CHARACTERS = 4096
function getMaxBase64EncodedLength(maxBytes: number): number {
if (maxBytes > MAX_SAFE_BASE64_INPUT_BYTES) return Number.MAX_SAFE_INTEGER
return Math.ceil(maxBytes / 3) * 4
}
function getBase64WhitespaceAllowance(maxEncodedLength: number): number {
return Math.min(Math.max(1024, Math.ceil(maxEncodedLength / 32)), MAX_BASE64_WHITESPACE_BYTES)
}
function getMaxBase64TransportLength(maxEncodedLength: number): number {
if (maxEncodedLength === Number.MAX_SAFE_INTEGER) return Number.MAX_SAFE_INTEGER
const whitespaceAllowance = getBase64WhitespaceAllowance(maxEncodedLength)
if (maxEncodedLength > Math.floor((Number.MAX_SAFE_INTEGER - whitespaceAllowance) / 3)) {
return Number.MAX_SAFE_INTEGER
}
return maxEncodedLength * 3 + whitespaceAllowance
}
function getMaxPercentEncodedLength(maxBytes: number): number {
if (maxBytes > MAX_SAFE_PERCENT_INPUT_BYTES) return Number.MAX_SAFE_INTEGER
return maxBytes * 4 + 4
}
/**
* Decodes a data URI without allowing its encoded or decoded representation to
* exceed the caller's byte budget. The first comma is the delimiter; later
* commas are payload and must be preserved.
*/
export function decodeDataUriWithinLimit(dataUri: string, maxBytes: number): DecodedDataUri {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
throw new RangeError('Data URI byte limit must be a non-negative safe integer')
}
const commaIndex = dataUri.indexOf(',')
if (dataUri.slice(0, 5).toLowerCase() !== 'data:' || commaIndex < 5) {
throw new FileParserError('invalid_format', 'Invalid data URI format')
}
if (commaIndex - 5 > MAX_DATA_URI_DESCRIPTOR_CHARACTERS) {
throw new FileParserError(
'complexity_limit',
`Data URI descriptor exceeds the safe limit of ${MAX_DATA_URI_DESCRIPTOR_CHARACTERS} characters`
)
}
const descriptor = dataUri.slice(5, commaIndex)
const descriptorParts = descriptor.split(';')
const mediaType = descriptorParts[0]?.trim() || null
const isBase64 = descriptorParts.slice(1).some((part) => part.toLowerCase() === 'base64')
const payloadStart = commaIndex + 1
const encodedPayloadLength = dataUri.length - payloadStart
const encodedCharacterLimit = isBase64
? getMaxBase64TransportLength(getMaxBase64EncodedLength(maxBytes))
: getMaxPercentEncodedLength(maxBytes)
if (encodedPayloadLength > encodedCharacterLimit) {
throw new FileParserError(
'complexity_limit',
`Data URI encoded payload exceeds the safe limit for a ${maxBytes}-byte file`
)
}
if (isBase64) {
const maxBase64EncodedLength = getMaxBase64EncodedLength(maxBytes)
const whitespaceAllowance = getBase64WhitespaceAllowance(maxBase64EncodedLength)
let base64CharacterCount = 0
let whitespaceTransportLength = 0
for (let index = payloadStart; index < dataUri.length; index++) {
let character = dataUri[index]
let transportLength = 1
if (character === '%') {
const hex = dataUri.slice(index + 1, index + 3)
if (!/^[0-9A-Fa-f]{2}$/.test(hex)) {
throw new FileParserError('invalid_format', 'Invalid percent-encoded data URI payload')
}
character = String.fromCharCode(Number.parseInt(hex, 16))
transportLength = 3
index += 2
}
if (/\s/.test(character)) {
whitespaceTransportLength += transportLength
if (whitespaceTransportLength > whitespaceAllowance) {
throw new FileParserError(
'complexity_limit',
`Data URI encoded payload exceeds the safe limit for a ${maxBytes}-byte file`
)
}
continue
}
base64CharacterCount++
if (base64CharacterCount > maxBase64EncodedLength) {
throw new FileParserError(
'complexity_limit',
`Data URI encoded payload exceeds the safe limit for a ${maxBytes}-byte file`
)
}
}
}
const encodedPayload = dataUri.slice(payloadStart)
let buffer: Buffer
if (isBase64) {
const compactPayload = encodedPayload
.replace(/%([0-9A-Fa-f]{2})/g, (_, hex: string) =>
String.fromCharCode(Number.parseInt(hex, 16))
)
.replace(/\s/g, '')
const paddingIndex = compactPayload.indexOf('=')
const unpaddedLength = paddingIndex === -1 ? compactPayload.length : paddingIndex
const paddingLength = compactPayload.length - unpaddedLength
const remainder = unpaddedLength % 4
const invalidPadding =
paddingLength > 2 ||
(paddingLength > 0 && compactPayload.length % 4 !== 0) ||
(paddingLength === 1 && remainder !== 3) ||
(paddingLength === 2 && remainder !== 2)
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(compactPayload) || remainder === 1 || invalidPadding) {
throw new FileParserError('invalid_format', 'Invalid base64 data URI payload')
}
const normalizedPayload =
paddingLength === 0
? compactPayload.padEnd(compactPayload.length + ((4 - remainder) % 4), '=')
: compactPayload
buffer = Buffer.from(normalizedPayload, 'base64')
} else {
const inputBoundedCapacity =
encodedPayloadLength > MAX_SAFE_PERCENT_INPUT_BYTES
? Number.MAX_SAFE_INTEGER
: encodedPayloadLength * 3 + 1
const decoded = Buffer.allocUnsafe(Math.min(maxBytes + 1, inputBoundedCapacity))
let decodedLength = 0
const appendByte = (byte: number): void => {
if (decodedLength <= maxBytes) decoded[decodedLength++] = byte
}
for (let index = 0; index < encodedPayload.length && decodedLength <= maxBytes; index++) {
const codeUnit = encodedPayload.charCodeAt(index)
if (codeUnit === 0x25) {
const hex = encodedPayload.slice(index + 1, index + 3)
if (!/^[0-9A-Fa-f]{2}$/.test(hex)) {
throw new FileParserError('invalid_format', 'Invalid percent-encoded data URI payload')
}
appendByte(Number.parseInt(hex, 16))
index += 2
} else if (codeUnit <= 0x7f) {
appendByte(codeUnit)
} else {
const codePoint = encodedPayload.codePointAt(index)!
for (const byte of Buffer.from(String.fromCodePoint(codePoint), 'utf8')) appendByte(byte)
if (codePoint > 0xffff) index++
}
}
buffer = decoded.subarray(0, decodedLength)
}
if (buffer.length > maxBytes) {
throw new FileParserError(
'complexity_limit',
`Data URI decoded payload is ${buffer.length} bytes, exceeding the safe limit of ${maxBytes} bytes`
)
}
return { buffer, mediaType }
}
+2 -1
View File
@@ -3,6 +3,7 @@
*/
import JSZip from 'jszip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ZipBombError } from '@/lib/file-parsers/ooxml-limits'
const { mockParseOfficeAsync, mockExtractRawText } = vi.hoisted(() => ({
mockParseOfficeAsync: vi.fn(),
@@ -53,7 +54,7 @@ describe('DocParser.parseBuffer', () => {
it('rejects a ZIP-shaped .doc whose declared expanded size exceeds the cap', async () => {
const bomb = await buildDeclaredOversizeArchive(2 * 1024 * 1024 * 1024)
await expect(new DocParser().parseBuffer(bomb)).rejects.toThrow(/exceeds the maximum allowed/)
await expect(new DocParser().parseBuffer(bomb)).rejects.toBeInstanceOf(ZipBombError)
})
it('rejects the bomb before either decompression library sees the buffer', async () => {
+14 -17
View File
@@ -1,6 +1,7 @@
import { existsSync } from 'fs'
import { readFile } from 'fs/promises'
import { createLogger } from '@sim/logger'
import { FileParserError } from '@/lib/file-parsers/errors'
import { loadParseOfficeAsync } from '@/lib/file-parsers/officeparser-module'
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
@@ -10,21 +11,16 @@ const logger = createLogger('DocParser')
export class DocParser implements FileParser {
async parseFile(filePath: string): Promise<FileParseResult> {
try {
if (!filePath) {
throw new Error('No file path provided')
}
if (!existsSync(filePath)) {
throw new Error(`File not found: ${filePath}`)
}
const buffer = await readFile(filePath)
return this.parseBuffer(buffer)
} catch (error) {
logger.error('DOC file parsing error:', error)
throw new Error(`Failed to parse DOC file: ${(error as Error).message}`)
if (!filePath) {
throw new Error('No file path provided')
}
if (!existsSync(filePath)) {
throw new Error(`File not found: ${filePath}`)
}
const buffer = await readFile(filePath)
return this.parseBuffer(buffer)
}
/**
@@ -36,13 +32,14 @@ export class DocParser implements FileParser {
async parseBuffer(buffer: Buffer): Promise<FileParseResult> {
try {
if (!buffer || buffer.length === 0) {
throw new Error('Empty buffer provided')
throw new FileParserError('empty_input', 'Empty buffer provided')
}
assertOoxmlArchiveWithinLimits(buffer)
const parseOfficeAsync = await loadParseOfficeAsync()
try {
const parseOfficeAsync = await loadParseOfficeAsync()
const result = await parseOfficeAsync(buffer)
if (result) {
@@ -85,7 +82,7 @@ export class DocParser implements FileParser {
return this.fallbackExtraction(buffer)
} catch (error) {
logger.error('DOC parsing error:', error)
throw new Error(`Failed to parse DOC buffer: ${(error as Error).message}`)
throw error
}
}
+42 -14
View File
@@ -1,6 +1,11 @@
import { readFile } from 'fs/promises'
import { createLogger } from '@sim/logger'
import mammoth from 'mammoth'
import {
FileParserError,
isEncryptedOfficeParserError,
toFileParserError,
} from '@/lib/file-parsers/errors'
import { loadParseOfficeAsync } from '@/lib/file-parsers/officeparser-module'
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
@@ -20,27 +25,25 @@ interface MammothResult {
export class DocxParser implements FileParser {
async parseFile(filePath: string): Promise<FileParseResult> {
try {
if (!filePath) {
throw new Error('No file path provided')
}
const buffer = await readFile(filePath)
return this.parseBuffer(buffer)
} catch (error) {
logger.error('DOCX file error:', error)
throw new Error(`Failed to parse DOCX file: ${(error as Error).message}`)
if (!filePath) {
throw new Error('No file path provided')
}
const buffer = await readFile(filePath)
return this.parseBuffer(buffer)
}
async parseBuffer(buffer: Buffer): Promise<FileParseResult> {
try {
if (!buffer || buffer.length === 0) {
throw new Error('Empty buffer provided')
throw new FileParserError('empty_input', 'Empty buffer provided')
}
assertOoxmlArchiveWithinLimits(buffer)
const extractionErrors: unknown[] = []
let parserReturnedEmpty = false
try {
const result = await mammoth.extractRawText({ buffer })
@@ -61,12 +64,15 @@ export class DocxParser implements FileParser {
},
}
}
parserReturnedEmpty = true
} catch (mammothError) {
logger.warn('mammoth failed, trying officeparser:', mammothError)
extractionErrors.push(mammothError)
}
const parseOfficeAsync = await loadParseOfficeAsync()
try {
const parseOfficeAsync = await loadParseOfficeAsync()
const result = await parseOfficeAsync(buffer)
if (result) {
@@ -83,8 +89,10 @@ export class DocxParser implements FileParser {
}
}
}
parserReturnedEmpty = true
} catch (officeError) {
logger.warn('officeparser failed:', officeError)
extractionErrors.push(officeError)
}
const isZipFile = buffer.length >= 2 && buffer[0] === 0x50 && buffer[1] === 0x4b
@@ -102,10 +110,30 @@ export class DocxParser implements FileParser {
}
}
throw new Error('Failed to extract text from DOCX file')
if (extractionErrors.some(isEncryptedOfficeParserError)) {
throw new FileParserError(
'encrypted_file',
'This document is encrypted or password-protected',
new AggregateError(extractionErrors)
)
}
if (parserReturnedEmpty) {
throw new FileParserError(
'no_extractable_text',
'No text could be extracted from this DOCX file',
extractionErrors.length > 0 ? new AggregateError(extractionErrors) : undefined
)
}
throw new FileParserError(
'invalid_format',
'The DOCX container could not be read',
new AggregateError(extractionErrors)
)
} catch (error) {
logger.error('DOCX parsing error:', error)
throw new Error(`Failed to parse DOCX buffer: ${(error as Error).message}`)
throw toFileParserError(error, 'invalid_format', 'Failed to parse DOCX buffer')
}
}
}
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import {
FileParserError,
isEncryptedOfficeParserError,
toFileParserError,
} from '@/lib/file-parsers/errors'
import { ArchiveIntegrityError, ZipBombError } from '@/lib/file-parsers/ooxml-limits'
describe('file parser errors', () => {
it('preserves an archive safety rejection through parser wrappers', () => {
const archiveError = new ZipBombError('Archive exceeds the expanded-size limit')
expect(toFileParserError(archiveError, 'invalid_format', 'DOCX parse failed')).toBe(
archiveError
)
})
it('preserves an archive integrity rejection through parser wrappers', () => {
const archiveError = new ArchiveIntegrityError('Archive entries overlap')
expect(toFileParserError(archiveError, 'invalid_format', 'DOCX parse failed')).toBe(
archiveError
)
})
it('preserves an existing typed parser failure', () => {
const parserError = new FileParserError('encrypted_file', 'Workbook is protected')
expect(toFileParserError(parserError, 'invalid_format', 'XLSX parse failed')).toBe(parserError)
})
it('retains an untyped parser-library exception as the cause', () => {
const libraryError = new Error('invalid central directory')
const parserError = toFileParserError(libraryError, 'invalid_format', 'DOCX parse failed')
expect(parserError).toBeInstanceOf(FileParserError)
expect(parserError.cause).toBe(libraryError)
})
it('bounds and normalizes an untyped parser-library diagnostic', () => {
const rawDiagnostic = `first line\nsecond line\u0000${'x'.repeat(1_000)}unbounded-tail`
const libraryError = new Error(rawDiagnostic)
const parserError = toFileParserError(libraryError, 'invalid_format', 'DOCX parse failed')
expect(parserError).toBeInstanceOf(FileParserError)
expect(parserError.message).not.toMatch(/[\n\u0000]/)
expect(parserError.message.length).toBeLessThanOrEqual('DOCX parse failed: '.length + 500)
expect(parserError.message).not.toContain('unbounded-tail')
expect(parserError.cause).toBe(libraryError)
})
it.each([
'File is password-protected',
'Password is required to open this workbook',
'Encrypted workbook is not supported',
])('recognizes the SheetJS encrypted-workbook error: %s', (message) => {
expect(isEncryptedOfficeParserError(new Error(message))).toBe(true)
})
})
+71
View File
@@ -0,0 +1,71 @@
import { getErrorMessage } from '@sim/utils/errors'
import { truncate } from '@sim/utils/string'
import { ArchiveIntegrityError, ZipBombError } from '@/lib/file-parsers/ooxml-limits'
const FILE_PARSER_DIAGNOSTIC_MAX_LENGTH = 500
export const FILE_PARSER_ERROR_CODES = [
'empty_input',
'unsupported_type',
'encrypted_file',
'no_extractable_text',
'invalid_format',
'complexity_limit',
'runtime_failure',
] as const
export type FileParserErrorCode = (typeof FILE_PARSER_ERROR_CODES)[number]
/** A typed failure reported at the untrusted-file parsing boundary. */
export class FileParserError extends Error {
readonly code: FileParserErrorCode
constructor(code: FileParserErrorCode, message: string, cause?: unknown) {
super(message, cause === undefined ? undefined : { cause })
this.name = 'FileParserError'
this.code = code
}
}
export function isFileParserError(error: unknown): error is FileParserError {
return error instanceof FileParserError
}
/**
* Wraps an untyped parser-library exception without erasing a typed inner cause.
* Archive safety and integrity failures remain typed so every caller can enforce
* the guard without knowing which parser happened to receive the archive.
*/
export function toFileParserError(
error: unknown,
code: FileParserErrorCode,
message: string
): FileParserError | ZipBombError | ArchiveIntegrityError {
if (
error instanceof ZipBombError ||
error instanceof ArchiveIntegrityError ||
isFileParserError(error)
) {
return error
}
const diagnostic = truncate(
getErrorMessage(error, 'Unknown parser error')
.replace(/[\u0000-\u001f\u007f]+/g, ' ')
.replace(/\s+/g, ' ')
.trim() || 'Unknown parser error',
FILE_PARSER_DIAGNOSTIC_MAX_LENGTH,
''
)
return new FileParserError(code, `${message}: ${diagnostic}`, error)
}
/**
* SheetJS exposes encrypted-workbook failures only through its exception text.
* Localizing that adapter-specific check here converts it to a stable code before
* it crosses the parser boundary; domain code never needs to match vendor text.
*/
export function isEncryptedOfficeParserError(error: unknown): boolean {
return /password[- ]protected|password is required|encrypted (?:file|workbook|document)/i.test(
getErrorMessage(error)
)
}
@@ -10,6 +10,12 @@ import { HtmlComplexityError, HtmlParser } from '@/lib/file-parsers/html-parser'
const parser = new HtmlParser()
describe('HtmlParser', () => {
it('reports empty input with the typed parser taxonomy', async () => {
await expect(parser.parseBuffer(Buffer.alloc(0))).rejects.toMatchObject({
code: 'empty_input',
})
})
describe('resource limits', () => {
/**
* Pinned by value: a 64 MB body aborts the process, so raising the cap
+12 -3
View File
@@ -2,6 +2,7 @@ import { readFile } from 'fs/promises'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import * as cheerio from 'cheerio'
import { FileParserError } from '@/lib/file-parsers/errors'
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
@@ -36,9 +37,9 @@ const MARKUP_TOKEN_BYTE = 0x3c
* Raised when a document exceeds the limits above, so an input rejected on
* resource grounds is not reported as a malformed file.
*/
export class HtmlComplexityError extends Error {
export class HtmlComplexityError extends FileParserError {
constructor(message: string) {
super(message)
super('complexity_limit', message)
this.name = 'HtmlComplexityError'
}
}
@@ -97,6 +98,10 @@ export class HtmlParser implements FileParser {
}
async parseBuffer(buffer: Buffer): Promise<FileParseResult> {
if (!buffer || buffer.length === 0) {
throw new FileParserError('empty_input', 'Empty buffer provided')
}
assertHtmlWithinLimits(buffer)
try {
@@ -164,7 +169,11 @@ export class HtmlParser implements FileParser {
}
logger.error('HTML buffer parsing error:', error)
throw new Error(`Failed to parse HTML buffer: ${getErrorMessage(error, 'Unknown error')}`)
throw new FileParserError(
'invalid_format',
`Failed to parse HTML buffer: ${getErrorMessage(error, 'Unknown error')}`,
error
)
}
}
+8 -3
View File
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { CsvParser } from '@/lib/file-parsers/csv-parser'
import { DocParser } from '@/lib/file-parsers/doc-parser'
import { DocxParser } from '@/lib/file-parsers/docx-parser'
import { FileParserError } from '@/lib/file-parsers/errors'
import { HtmlParser } from '@/lib/file-parsers/html-parser'
import {
parseJSON,
@@ -125,7 +126,7 @@ export async function parseFile(filePath: string): Promise<FileParseResult> {
export async function parseBuffer(buffer: Buffer, extension: string): Promise<FileParseResult> {
try {
if (!buffer || buffer.length === 0) {
throw new Error('Empty buffer provided')
throw new FileParserError('empty_input', 'Empty buffer provided')
}
if (!extension) {
@@ -138,13 +139,17 @@ export async function parseBuffer(buffer: Buffer, extension: string): Promise<Fi
const parser = PARSERS.get(normalizedExtension)
if (!parser) {
throw new Error(
throw new FileParserError(
'unsupported_type',
`Unsupported file type: ${normalizedExtension}. Supported types are: ${SUPPORTED_EXTENSIONS_TEXT}`
)
}
if (!parser.parseBuffer) {
throw new Error(`Parser for ${normalizedExtension} does not support buffer parsing`)
throw new FileParserError(
'unsupported_type',
`Parser for ${normalizedExtension} does not support buffer parsing`
)
}
return await parser.parseBuffer(buffer)
@@ -0,0 +1,45 @@
/**
* @vitest-environment node
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { parseJSONBuffer, parseJSONLBuffer } from '@/lib/file-parsers/json-parser'
describe('JSON parser complexity limits', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('rejects excessive nesting before serializing the parsed value', async () => {
const content = `${'['.repeat(501)}0${']'.repeat(501)}`
const stringify = vi.spyOn(JSON, 'stringify')
await expect(parseJSONBuffer(Buffer.from(content))).rejects.toMatchObject({
code: 'complexity_limit',
})
expect(stringify).not.toHaveBeenCalled()
})
it('validates every JSONL item before serializing the aggregate', async () => {
const nested = `${'['.repeat(501)}0${']'.repeat(501)}`
const stringify = vi.spyOn(JSON, 'stringify')
await expect(parseJSONLBuffer(Buffer.from(`{}\n${nested}`))).rejects.toMatchObject({
code: 'complexity_limit',
})
expect(stringify).not.toHaveBeenCalled()
})
it('reports the deepest JSONL item instead of only the first', async () => {
const result = await parseJSONLBuffer(Buffer.from('{}\n{"nested":{"value":true}}'))
expect(result.metadata).toMatchObject({ itemCount: 2, depth: 3 })
})
it('preserves ordinary JSON content and metadata', async () => {
const result = await parseJSONBuffer(Buffer.from('{"items":[1,2],"name":"test"}'))
expect(JSON.parse(result.content)).toEqual({ items: [1, 2], name: 'test' })
expect(result.metadata).toMatchObject({ isArray: false, keys: ['items', 'name'], depth: 2 })
})
})
+214 -87
View File
@@ -1,118 +1,245 @@
import { getErrorMessage } from '@sim/utils/errors'
import { FileParserError } from '@/lib/file-parsers/errors'
import type { FileParseResult } from '@/lib/file-parsers/types'
const MAX_JSON_DEPTH = 500
const MAX_JSON_NODES = 1_000_000
const MAX_JSON_SERIALIZED_UNITS = 64 * 1024 * 1024
interface JsonComplexityBudget {
nodes: number
serializedUnits: number
}
interface JsonTraversalFrame {
value: unknown[] | Record<string, unknown>
depth: number
index: number
keys?: string[]
}
/**
* Parse JSON files
* Returns the exact number of UTF-16 code units JSON serialization needs for a
* string, including quotes and escape expansion, without allocating the result.
*/
export async function parseJSON(filePath: string): Promise<FileParseResult> {
const fs = await import('fs/promises')
const content = await fs.readFile(filePath, 'utf-8')
try {
// Parse to validate JSON
const jsonData = JSON.parse(content)
// Return pretty-printed JSON for better readability
const formattedContent = JSON.stringify(jsonData, null, 2)
// Extract metadata about the JSON structure
const metadata = {
type: 'json',
isArray: Array.isArray(jsonData),
keys: Array.isArray(jsonData) ? [] : Object.keys(jsonData),
itemCount: Array.isArray(jsonData) ? jsonData.length : undefined,
depth: getJsonDepth(jsonData),
function serializedJsonStringLength(value: string): number {
let length = 2
for (let index = 0; index < value.length; index++) {
const code = value.charCodeAt(index)
if (code === 0x22 || code === 0x5c) {
length += 2
} else if (code < 0x20) {
length +=
code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d ? 2 : 6
} else if (code >= 0xd800 && code <= 0xdfff) {
const next = index + 1 < value.length ? value.charCodeAt(index + 1) : 0
if (code <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) {
length += 2
index++
} else {
length += 6
}
} else {
length++
}
}
return length
}
return {
content: formattedContent,
metadata,
}
} catch (error) {
throw new Error(`Invalid JSON: ${getErrorMessage(error, 'Unknown error')}`)
function estimateJsonValueUnits(value: unknown, depth: number): number {
const formattingOverhead = depth * 2 + 4
if (typeof value === 'string') {
return formattingOverhead + serializedJsonStringLength(value)
}
if (typeof value === 'number') return formattingOverhead + String(value).length
if (typeof value === 'boolean') return formattingOverhead + (value ? 4 : 5)
if (value === null) return formattingOverhead + 4
return formattingOverhead + 2
}
function chargeJsonComplexity(
budget: JsonComplexityBudget,
value: unknown,
depth: number,
key?: string
): void {
budget.nodes++
if (budget.nodes > MAX_JSON_NODES) {
throw new FileParserError(
'complexity_limit',
`JSON document exceeds the maximum of ${MAX_JSON_NODES.toLocaleString()} values`
)
}
budget.serializedUnits +=
estimateJsonValueUnits(value, depth) +
(key === undefined ? 0 : serializedJsonStringLength(key) + 2)
if (budget.serializedUnits > MAX_JSON_SERIALIZED_UNITS) {
throw new FileParserError(
'complexity_limit',
`JSON document expands beyond the maximum serialized size of ${MAX_JSON_SERIALIZED_UNITS} characters`
)
}
}
/**
* Parse JSON from buffer
* Iteratively validates a parsed JSON value before pretty-printing it. The
* traversal keeps only one frame per nesting level and avoids recursively
* materializing child-value arrays while trying to reject the document.
*/
export async function parseJSONBuffer(buffer: Buffer): Promise<FileParseResult> {
const content = buffer.toString('utf-8')
function assertJsonValueWithinLimits(
root: unknown,
budget: JsonComplexityBudget,
initialDepth = 0
): number {
let maxDepth = initialDepth
const stack: JsonTraversalFrame[] = []
try {
const jsonData = JSON.parse(content)
const formattedContent = JSON.stringify(jsonData, null, 2)
const metadata = {
type: 'json',
isArray: Array.isArray(jsonData),
keys: Array.isArray(jsonData) ? [] : Object.keys(jsonData),
itemCount: Array.isArray(jsonData) ? jsonData.length : undefined,
depth: getJsonDepth(jsonData),
const enterValue = (value: unknown, depth: number, key?: string): void => {
chargeJsonComplexity(budget, value, depth, key)
if (value === null || typeof value !== 'object') return
if (depth >= MAX_JSON_DEPTH) {
throw new FileParserError(
'complexity_limit',
`JSON document exceeds the maximum nesting depth of ${MAX_JSON_DEPTH}`
)
}
return {
content: formattedContent,
metadata,
}
} catch (error) {
throw new Error(`Invalid JSON: ${getErrorMessage(error, 'Unknown error')}`)
}
}
/**
* Parse JSONL (JSON Lines) files — one JSON object per line
*/
export async function parseJSONL(filePath: string): Promise<FileParseResult> {
const fs = await import('fs/promises')
const content = await fs.readFile(filePath, 'utf-8')
return parseJSONLContent(content)
}
/**
* Parse JSONL from buffer
*/
export async function parseJSONLBuffer(buffer: Buffer): Promise<FileParseResult> {
const content = buffer.toString('utf-8')
return parseJSONLContent(content)
}
function parseJSONLContent(content: string): FileParseResult {
const lines = content.split('\n').filter((line) => line.trim())
const items: unknown[] = []
for (const line of lines) {
try {
items.push(JSON.parse(line))
} catch {
throw new Error(`Invalid JSONL: failed to parse line: ${line.slice(0, 100)}`)
maxDepth = Math.max(maxDepth, depth + 1)
if (Array.isArray(value)) {
stack.push({ value, depth, index: 0 })
} else {
const record = value as Record<string, unknown>
stack.push({ value: record, depth, index: 0, keys: Object.keys(record) })
}
}
const formattedContent = JSON.stringify(items, null, 2)
enterValue(root, initialDepth)
while (stack.length > 0) {
const frame = stack.at(-1)!
if (Array.isArray(frame.value)) {
if (frame.index >= frame.value.length) {
stack.pop()
continue
}
const child = frame.value[frame.index]
frame.index++
enterValue(child, frame.depth + 1)
continue
}
const keys = frame.keys!
if (frame.index >= keys.length) {
stack.pop()
continue
}
const key = keys[frame.index]
frame.index++
enterValue(frame.value[key], frame.depth + 1, key)
}
return maxDepth
}
function buildJsonResult(jsonData: unknown): FileParseResult {
const budget = { nodes: 0, serializedUnits: 0 }
const depth = assertJsonValueWithinLimits(jsonData, budget)
const formattedContent = JSON.stringify(jsonData, null, 2)
const isArray = Array.isArray(jsonData)
const isRecord = jsonData !== null && typeof jsonData === 'object' && !isArray
return {
content: formattedContent,
metadata: {
type: 'json',
isArray: true,
keys: [],
itemCount: items.length,
depth: items.length > 0 ? 1 + getJsonDepth(items[0]) : 1,
isArray,
keys: isRecord ? Object.keys(jsonData as Record<string, unknown>) : [],
itemCount: isArray ? jsonData.length : undefined,
depth,
},
}
}
/**
* Calculate the depth of a JSON object
*/
function getJsonDepth(obj: any): number {
if (obj === null || typeof obj !== 'object') return 0
function parseJsonContent(content: string): FileParseResult {
try {
return buildJsonResult(JSON.parse(content))
} catch (error) {
if (error instanceof FileParserError) throw error
if (!(error instanceof SyntaxError)) {
throw new FileParserError('runtime_failure', 'JSON processing failed unexpectedly', error)
}
throw new FileParserError(
'invalid_format',
`Invalid JSON: ${getErrorMessage(error, 'Unknown error')}`,
error
)
}
}
if (Array.isArray(obj)) {
return obj.length > 0 ? 1 + Math.max(...obj.map(getJsonDepth)) : 1
/** Parse a JSON file. */
export async function parseJSON(filePath: string): Promise<FileParseResult> {
const fs = await import('fs/promises')
return parseJsonContent(await fs.readFile(filePath, 'utf-8'))
}
/** Parse JSON from a buffer. */
export async function parseJSONBuffer(buffer: Buffer): Promise<FileParseResult> {
return parseJsonContent(buffer.toString('utf-8'))
}
function* iterateJsonLines(content: string): Generator<{ line: string; lineNumber: number }> {
let lineStart = 0
let lineNumber = 1
while (lineStart <= content.length) {
const newline = content.indexOf('\n', lineStart)
const lineEnd = newline === -1 ? content.length : newline
const line = content.slice(lineStart, lineEnd)
if (line.trim()) yield { line, lineNumber }
if (newline === -1) break
lineStart = newline + 1
lineNumber++
}
}
function parseJsonLinesContent(content: string): FileParseResult {
const items: unknown[] = []
const budget = { nodes: 0, serializedUnits: 0 }
let depth = assertJsonValueWithinLimits([], budget)
for (const { line, lineNumber } of iterateJsonLines(content)) {
let item: unknown
try {
item = JSON.parse(line)
} catch (error) {
throw new FileParserError(
'invalid_format',
`Invalid JSONL on line ${lineNumber}: ${line.slice(0, 100)}`,
error
)
}
depth = Math.max(depth, assertJsonValueWithinLimits(item, budget, 1))
items.push(item)
}
const depths = Object.values(obj).map(getJsonDepth)
return depths.length > 0 ? 1 + Math.max(...depths) : 1
return {
content: JSON.stringify(items, null, 2),
metadata: {
type: 'json',
isArray: true,
keys: [],
itemCount: items.length,
depth,
},
}
}
/** Parse a JSON Lines file. */
export async function parseJSONL(filePath: string): Promise<FileParseResult> {
const fs = await import('fs/promises')
return parseJsonLinesContent(await fs.readFile(filePath, 'utf-8'))
}
/** Parse JSON Lines from a buffer. */
export async function parseJSONLBuffer(buffer: Buffer): Promise<FileParseResult> {
return parseJsonLinesContent(buffer.toString('utf-8'))
}
@@ -1,3 +1,5 @@
import { FileParserError } from '@/lib/file-parsers/errors'
/** `officeparser`'s single entry point, as every parser here calls it. */
type ParseOfficeAsync = (input: Buffer) => Promise<string>
@@ -46,5 +48,13 @@ export function resolveParseOfficeAsync(mod: OfficeParserModule): ParseOfficeAsy
* can only assert the shape that already worked.
*/
export async function loadParseOfficeAsync(): Promise<ParseOfficeAsync> {
return resolveParseOfficeAsync((await import('officeparser')) as OfficeParserModule)
try {
return resolveParseOfficeAsync((await import('officeparser')) as OfficeParserModule)
} catch (error) {
throw new FileParserError(
'runtime_failure',
'The officeparser runtime could not be loaded',
error
)
}
}
+14
View File
@@ -15,9 +15,23 @@ export const MAX_OOXML_TOTAL_UNCOMPRESSED_BYTES = 150 * 1024 * 1024
/** Hard ceiling on any single entry's declared uncompressed size. */
export const MAX_OOXML_ENTRY_UNCOMPRESSED_BYTES = 64 * 1024 * 1024
/** Bounds the per-entry object graph created by OOXML ZIP parsers. */
export const MAX_OOXML_CENTRAL_DIRECTORY_RECORDS = 10_000
/** Bounds retained central-directory extra-field metadata before ZIP parsing. */
export const MAX_OOXML_CENTRAL_DIRECTORY_EXTRA_BYTES = 4 * 1024 * 1024
export class ZipBombError extends Error {
constructor(message: string) {
super(message)
this.name = 'ZipBombError'
}
}
/** A ZIP-shaped file whose structure cannot be verified safely or parsed consistently. */
export class ArchiveIntegrityError extends Error {
constructor(message: string) {
super(message)
this.name = 'ArchiveIntegrityError'
}
}
@@ -1,6 +1,7 @@
import { existsSync } from 'fs'
import { readFile } from 'fs/promises'
import { createLogger } from '@sim/logger'
import { FileParserError, isEncryptedOfficeParserError } from '@/lib/file-parsers/errors'
import { loadParseOfficeAsync } from '@/lib/file-parsers/officeparser-module'
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
@@ -38,7 +39,7 @@ export class OpenDocumentParser implements FileParser {
async parseBuffer(buffer: Buffer): Promise<FileParseResult> {
if (!buffer || buffer.length === 0) {
throw new Error('Empty buffer provided')
throw new FileParserError('empty_input', 'Empty buffer provided')
}
/**
@@ -55,12 +56,22 @@ export class OpenDocumentParser implements FileParser {
extracted = typeof result === 'string' ? result : ''
} catch (error) {
logger.error('OpenDocument parsing failed', { error: (error as Error).message })
throw new Error(`Failed to parse OpenDocument file: ${(error as Error).message}`)
if (isEncryptedOfficeParserError(error)) {
throw new FileParserError(
'encrypted_file',
'This OpenDocument file is encrypted or password-protected',
error
)
}
throw new FileParserError('invalid_format', 'Failed to parse OpenDocument file', error)
}
const content = sanitizeTextForUTF8(extracted.trim())
if (!content) {
throw new Error('Failed to extract text from OpenDocument file')
throw new FileParserError(
'no_extractable_text',
'No text could be extracted from this OpenDocument file'
)
}
return {
@@ -14,6 +14,7 @@ import * as XLSX from 'xlsx'
import { parseBuffer } from '@/lib/file-parsers'
import { DocParser } from '@/lib/file-parsers/doc-parser'
import { DocxParser } from '@/lib/file-parsers/docx-parser'
import { FileParserError } from '@/lib/file-parsers/errors'
import { OpenDocumentParser } from '@/lib/file-parsers/opendocument-parser'
import { PptxParser } from '@/lib/file-parsers/pptx-parser'
import { XlsxParser } from '@/lib/file-parsers/xlsx-parser'
@@ -184,6 +185,15 @@ describe('DocxParser', () => {
expect(result.metadata?.degraded).toBeFalsy()
})
it('reports a valid image-only or empty Word container as no extractable text', async () => {
const buffer = await buildDocx('<w:p><w:r><w:drawing/></w:r></w:p>')
const error = await new DocxParser().parseBuffer(buffer).catch((caught: unknown) => caught)
expect(error).toBeInstanceOf(FileParserError)
expect(error).toMatchObject({ code: 'no_extractable_text' })
})
/**
* A macro-enabled `.docm` is the same WordprocessingML package with a different
* main-part content type. mammoth reads `word/document.xml` without consulting
@@ -0,0 +1,30 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
const { mockParseOfficeAsync } = vi.hoisted(() => ({
mockParseOfficeAsync: vi.fn(),
}))
vi.mock('@/lib/file-parsers/officeparser-module', () => ({
loadParseOfficeAsync: vi.fn(async () => mockParseOfficeAsync),
}))
import type { FileParserError } from '@/lib/file-parsers/errors'
import { PptxParser } from '@/lib/file-parsers/pptx-parser'
describe('PptxParser', () => {
it('classifies encrypted legacy presentations before degraded extraction', async () => {
const libraryError = new Error('File is password-protected')
mockParseOfficeAsync.mockRejectedValueOnce(libraryError)
const legacyOleBuffer = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1])
const result = new PptxParser().parseBuffer(legacyOleBuffer)
await expect(result).rejects.toMatchObject<FileParserError>({
code: 'encrypted_file',
cause: libraryError,
})
})
})
+50 -48
View File
@@ -1,6 +1,7 @@
import { existsSync } from 'fs'
import { readFile } from 'fs/promises'
import { createLogger } from '@sim/logger'
import { FileParserError, isEncryptedOfficeParserError } from '@/lib/file-parsers/errors'
import { loadParseOfficeAsync } from '@/lib/file-parsers/officeparser-module'
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
@@ -10,68 +11,69 @@ const logger = createLogger('PptxParser')
export class PptxParser implements FileParser {
async parseFile(filePath: string): Promise<FileParseResult> {
try {
if (!filePath) {
throw new Error('No file path provided')
}
if (!existsSync(filePath)) {
throw new Error(`File not found: ${filePath}`)
}
logger.info(`Parsing PowerPoint file: ${filePath}`)
const buffer = await readFile(filePath)
return this.parseBuffer(buffer)
} catch (error) {
logger.error('PowerPoint file parsing error:', error)
throw new Error(`Failed to parse PowerPoint file: ${(error as Error).message}`)
if (!filePath) {
throw new Error('No file path provided')
}
if (!existsSync(filePath)) {
throw new Error(`File not found: ${filePath}`)
}
logger.info(`Parsing PowerPoint file: ${filePath}`)
const buffer = await readFile(filePath)
return this.parseBuffer(buffer)
}
async parseBuffer(buffer: Buffer): Promise<FileParseResult> {
logger.info('Parsing PowerPoint buffer, size:', buffer.length)
if (!buffer || buffer.length === 0) {
throw new FileParserError('empty_input', 'Empty buffer provided')
}
assertOoxmlArchiveWithinLimits(buffer)
const parseOfficeAsync = await loadParseOfficeAsync()
try {
logger.info('Parsing PowerPoint buffer, size:', buffer.length)
const result = await parseOfficeAsync(buffer)
if (!buffer || buffer.length === 0) {
throw new Error('Empty buffer provided')
}
assertOoxmlArchiveWithinLimits(buffer)
let parseOfficeAsync
try {
parseOfficeAsync = await loadParseOfficeAsync()
} catch (importError) {
logger.warn('officeparser not available, using fallback extraction')
if (!result || typeof result !== 'string') {
return this.fallbackExtraction(buffer)
}
try {
const result = await parseOfficeAsync(buffer)
const content = sanitizeTextForUTF8(result.trim())
if (!result || typeof result !== 'string') {
throw new Error('officeparser returned invalid result')
}
logger.info('PowerPoint parsing completed successfully with officeparser')
const content = sanitizeTextForUTF8(result.trim())
return {
content: content,
metadata: {
characterCount: content.length,
extractionMethod: 'officeparser',
},
}
} catch (extractError) {
if (isEncryptedOfficeParserError(extractError)) {
throw new FileParserError(
'encrypted_file',
'This presentation is encrypted or password-protected',
extractError
)
}
logger.info('PowerPoint parsing completed successfully with officeparser')
return {
content: content,
metadata: {
characterCount: content.length,
extractionMethod: 'officeparser',
},
}
} catch (extractError) {
logger.warn('officeparser failed, using fallback:', extractError)
const isZipFile = buffer.length >= 2 && buffer[0] === 0x50 && buffer[1] === 0x4b
if (!isZipFile) {
logger.warn('officeparser failed for legacy PowerPoint, using fallback:', extractError)
return this.fallbackExtraction(buffer)
}
} catch (error) {
logger.error('PowerPoint buffer parsing error:', error)
throw new Error(`Failed to parse PowerPoint buffer: ${(error as Error).message}`)
throw new FileParserError(
'invalid_format',
'The PowerPoint container could not be read',
extractError
)
}
}
+142 -73
View File
@@ -3,6 +3,11 @@ import { readFile } from 'fs/promises'
import { createLogger } from '@sim/logger'
import { truncate } from '@sim/utils/string'
import * as XLSX from 'xlsx'
import {
FileParserError,
isEncryptedOfficeParserError,
toFileParserError,
} from '@/lib/file-parsers/errors'
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
import { sanitizeTextForUTF8, truncationNotice } from '@/lib/file-parsers/utils'
import { assertOoxmlArchiveWithinLimits } from '@/lib/file-parsers/zip-guard'
@@ -12,35 +17,48 @@ const logger = createLogger('XlsxParser')
// Configuration for handling large XLSX files
const CONFIG = {
MAX_PREVIEW_ROWS: 1000, // Only keep first 1000 rows for preview
MAX_PREVIEW_COLUMNS: 256,
MAX_SAMPLE_ROWS: 100, // Sample for metadata
ROWS_PER_CHUNK: 50, // Aggregate 50 rows per chunk to reduce chunk count
MAX_CELL_LENGTH: 1000, // Truncate very long cell values
MAX_SAMPLE_COLUMNS: 32,
MAX_SAMPLE_CELL_LENGTH: 256,
MAX_SAMPLE_CHARACTERS: 1024 * 1024,
MAX_CONTENT_SIZE: 10 * 1024 * 1024, // 10MB max content size
}
const CONTENT_LIMIT_NOTICE = truncationNotice('Content truncated due to size limits')
function sliceUtf8WithinByteLimit(value: string, maxBytes: number): string {
if (maxBytes <= 0) return ''
if (Buffer.byteLength(value, 'utf8') <= maxBytes) return value
let low = 0
let high = value.length
while (low < high) {
const middle = Math.ceil((low + high) / 2)
if (Buffer.byteLength(value.slice(0, middle), 'utf8') <= maxBytes) low = middle
else high = middle - 1
}
return value.slice(0, low)
}
export class XlsxParser implements FileParser {
/**
* Read the file into a buffer and delegate to {@link parseBuffer} so the
* decompression-bomb guard runs before SheetJS inflates the workbook.
*/
async parseFile(filePath: string): Promise<FileParseResult> {
try {
if (!filePath) {
throw new Error('No file path provided')
}
if (!existsSync(filePath)) {
throw new Error(`File not found: ${filePath}`)
}
logger.info(`Parsing XLSX file: ${filePath}`)
const buffer = await readFile(filePath)
return this.parseBuffer(buffer)
} catch (error) {
logger.error('XLSX file parsing error:', error)
throw new Error(`Failed to parse XLSX file: ${(error as Error).message}`)
if (!filePath) {
throw new Error('No file path provided')
}
if (!existsSync(filePath)) {
throw new Error(`File not found: ${filePath}`)
}
logger.info(`Parsing XLSX file: ${filePath}`)
const buffer = await readFile(filePath)
return this.parseBuffer(buffer)
}
async parseBuffer(buffer: Buffer): Promise<FileParseResult> {
@@ -51,7 +69,7 @@ export class XlsxParser implements FileParser {
)
if (!buffer || buffer.length === 0) {
throw new Error('Empty buffer provided')
throw new FileParserError('empty_input', 'Empty buffer provided')
}
assertOoxmlArchiveWithinLimits(buffer)
@@ -65,7 +83,14 @@ export class XlsxParser implements FileParser {
return this.processWorkbook(workbook)
} catch (error) {
logger.error('XLSX buffer parsing error:', error)
throw new Error(`Failed to parse XLSX buffer: ${(error as Error).message}`)
if (isEncryptedOfficeParserError(error)) {
throw new FileParserError(
'encrypted_file',
'This workbook is encrypted or password-protected',
error
)
}
throw toFileParserError(error, 'invalid_format', 'Failed to parse XLSX buffer')
}
}
@@ -75,7 +100,36 @@ export class XlsxParser implements FileParser {
let totalRows = 0
let truncated = false
let contentSize = 0
const sampledData: any[] = []
let hasMeaningfulCellContent = false
let outputLimitReached = false
let sampleCharacters = 0
const sampledData: unknown[][] = []
const appendContent = (value: string): boolean => {
const remaining = CONFIG.MAX_CONTENT_SIZE - contentSize
const valueBytes = Buffer.byteLength(value, 'utf8')
if (valueBytes <= remaining) {
content += value
contentSize += valueBytes
return true
}
const noticeBytes = Buffer.byteLength(CONTENT_LIMIT_NOTICE, 'utf8')
const retainedContent = sliceUtf8WithinByteLimit(
content,
Math.max(0, CONFIG.MAX_CONTENT_SIZE - noticeBytes)
)
const retainedBytes = Buffer.byteLength(retainedContent, 'utf8')
const prefix = sliceUtf8WithinByteLimit(
value,
Math.max(0, CONFIG.MAX_CONTENT_SIZE - retainedBytes - noticeBytes)
)
content = retainedContent + prefix + CONTENT_LIMIT_NOTICE
contentSize = Buffer.byteLength(content, 'utf8')
truncated = true
outputLimitReached = true
return false
}
for (const sheetName of sheetNames) {
const worksheet = workbook.Sheets[sheetName]
@@ -83,6 +137,7 @@ export class XlsxParser implements FileParser {
// Get sheet dimensions
const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1')
const rowCount = range.e.r - range.s.r + 1
const columnCount = range.e.c - range.s.c + 1
logger.info(`Processing sheet: ${sheetName} with ${rowCount} rows`)
@@ -106,10 +161,14 @@ export class XlsxParser implements FileParser {
* defeated the `blankrows: false` beside it.
*/
const lastPreviewRow = Math.min(range.e.r, range.s.r + CONFIG.MAX_PREVIEW_ROWS - 1)
const sheetData = XLSX.utils.sheet_to_json(worksheet, {
const lastPreviewColumn = Math.min(range.e.c, range.s.c + CONFIG.MAX_PREVIEW_COLUMNS - 1)
const sheetData = XLSX.utils.sheet_to_json<unknown[]>(worksheet, {
header: 1,
blankrows: false, // Skip blank rows
range: { s: { r: range.s.r, c: range.s.c }, e: { r: lastPreviewRow, c: range.e.c } },
range: {
s: { r: range.s.r, c: range.s.c },
e: { r: lastPreviewRow, c: lastPreviewColumn },
},
})
// Reported from the declared range, as before, so bounding the conversion
@@ -117,10 +176,34 @@ export class XlsxParser implements FileParser {
const actualRowCount = sheetData.length
totalRows += rowCount
for (const row of sheetData) {
if (row.some((cell) => this.truncateCell(cell).trim().length > 0)) {
hasMeaningfulCellContent = true
break
}
}
// Store limited sample for metadata
if (sampledData.length < CONFIG.MAX_SAMPLE_ROWS) {
const sampleSize = Math.min(CONFIG.MAX_SAMPLE_ROWS - sampledData.length, actualRowCount)
sampledData.push(...sheetData.slice(0, sampleSize))
for (const row of sheetData.slice(0, sampleSize)) {
const sampleRow: string[] = []
for (const cell of row.slice(0, CONFIG.MAX_SAMPLE_COLUMNS)) {
const remaining = CONFIG.MAX_SAMPLE_CHARACTERS - sampleCharacters
if (remaining <= 0) break
const value = this.truncateCell(
cell,
Math.min(CONFIG.MAX_SAMPLE_CELL_LENGTH, remaining)
)
sampleRow.push(value)
sampleCharacters += value.length
}
if (sampleRow.length > 0) sampledData.push(sampleRow)
if (sampleCharacters >= CONFIG.MAX_SAMPLE_CHARACTERS) break
}
}
// Already bounded by the conversion window above.
@@ -129,53 +212,28 @@ export class XlsxParser implements FileParser {
// Add sheet header
const sheetHeader = `\n=== Sheet: ${cleanSheetName} ===\n`
content += sheetHeader
contentSize += sheetHeader.length
if (!appendContent(sheetHeader)) break
if (actualRowCount > 0) {
// Get headers if available
const headers = sheetData[0] as any[]
const headers = sheetData[0]
if (headers && headers.length > 0) {
const headerRow = headers.map((h) => this.truncateCell(h)).join('\t')
content += `${headerRow}\n`
content += `${'-'.repeat(Math.min(80, headerRow.length))}\n`
contentSize += headerRow.length + 82
}
// Process data rows in chunks
let chunkContent = ''
let chunkRowCount = 0
for (let i = 1; i < rowsToProcess; i++) {
const row = sheetData[i] as any[]
if (row && row.length > 0) {
const rowString = row.map((cell) => this.truncateCell(cell)).join('\t')
chunkContent += `${rowString}\n`
chunkRowCount++
// Add chunk separator every N rows for better readability
if (chunkRowCount >= CONFIG.ROWS_PER_CHUNK) {
content += chunkContent
contentSize += chunkContent.length
chunkContent = ''
chunkRowCount = 0
// Check content size limit
if (contentSize > CONFIG.MAX_CONTENT_SIZE) {
truncated = true
break
}
}
if (!appendContent(`${headerRow}\n${'-'.repeat(Math.min(80, headerRow.length))}\n`)) {
break
}
}
// Add remaining chunk content
if (chunkContent && contentSize < CONFIG.MAX_CONTENT_SIZE) {
content += chunkContent
contentSize += chunkContent.length
for (let i = 1; i < rowsToProcess; i++) {
const row = sheetData[i]
if (row && row.length > 0) {
const rowString = row.map((cell) => this.truncateCell(cell)).join('\t')
if (!appendContent(`${rowString}\n`)) break
}
}
if (outputLimitReached) break
/**
* Truncated means the WINDOW cut the sheet short, which is a question
* about the declared range against the cap — not about how many rows
@@ -186,21 +244,26 @@ export class XlsxParser implements FileParser {
* rows, since those are now skipped. The CSV parser asks the same
* question the same way.
*/
if (rowCount > CONFIG.MAX_PREVIEW_ROWS) {
content += truncationNotice(
`${rowCount.toLocaleString()} total rows, showing first ${rowsToProcess.toLocaleString()}`
if (rowCount > CONFIG.MAX_PREVIEW_ROWS || columnCount > CONFIG.MAX_PREVIEW_COLUMNS) {
const rowSummary = `${rowCount.toLocaleString()} total rows, showing first ${rowsToProcess.toLocaleString()}`
const columnSummary = `${columnCount.toLocaleString()} total columns, showing first ${Math.min(columnCount, CONFIG.MAX_PREVIEW_COLUMNS).toLocaleString()}`
appendContent(
truncationNotice(
[
rowCount > CONFIG.MAX_PREVIEW_ROWS ? rowSummary : null,
columnCount > CONFIG.MAX_PREVIEW_COLUMNS ? columnSummary : null,
]
.filter((summary): summary is string => summary !== null)
.join('; ')
)
)
truncated = true
}
} else {
content += '[Empty sheet]\n'
appendContent('[Empty sheet]\n')
}
if (contentSize > CONFIG.MAX_CONTENT_SIZE) {
content += truncationNotice('Content truncated due to size limits')
truncated = true
break
}
if (outputLimitReached) break
}
logger.info(
@@ -216,21 +279,27 @@ export class XlsxParser implements FileParser {
sheetNames: sheetNames,
totalRows: totalRows,
truncated: truncated,
degraded: !hasMeaningfulCellContent,
sampledData: sampledData.slice(0, CONFIG.MAX_SAMPLE_ROWS),
contentSize: contentSize,
},
}
}
private truncateCell(cell: any): string {
private truncateCell(cell: unknown, maxLength?: number): string {
if (cell === null || cell === undefined) {
return ''
}
let cellStr = String(cell)
// Truncate very long cells
cellStr = truncate(cellStr, CONFIG.MAX_CELL_LENGTH)
/**
* Samples are previews; canonical content is bounded only by the aggregate
* output ceiling so an otherwise valid long cell is never silently partial.
*/
if (maxLength !== undefined && cellStr.length > maxLength) {
cellStr = truncate(cellStr, Math.max(0, maxLength - 3))
}
return sanitizeTextForUTF8(cellStr)
}
@@ -34,7 +34,7 @@ describe('XlsxParser preview bound', () => {
expect(toJson).toHaveBeenCalled()
const options = toJson.mock.calls[0][1] as {
range?: { s: { r: number }; e: { r: number } }
range?: { s: { r: number; c: number }; e: { r: number; c: number } }
defval?: unknown
}
@@ -49,6 +49,11 @@ describe('XlsxParser preview bound', () => {
(options.range as { s: { r: number }; e: { r: number } }).s.r +
1
expect(rowsRequested).toBeLessThanOrEqual(1000)
const columnsRequested =
(options.range as { s: { c: number }; e: { c: number } }).e.c -
(options.range as { s: { c: number }; e: { c: number } }).s.c +
1
expect(columnsRequested).toBeLessThanOrEqual(256)
/**
* `defval` made every cell in the range materialize, so allocation scaled
@@ -58,6 +63,43 @@ describe('XlsxParser preview bound', () => {
expect(options.defval).toBeUndefined()
})
it('caps a sheet with an inflated declared column range before conversion', async () => {
const sheet = XLSX.utils.aoa_to_sheet([['header'], ['value']])
sheet['!ref'] = 'A1:XFD2'
const book = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(book, sheet, 'Wide')
const buffer = XLSX.write(book, { type: 'buffer', bookType: 'xlsx' }) as Buffer
const toJson = vi.spyOn(XLSX.utils, 'sheet_to_json')
const result = await new XlsxParser().parseBuffer(buffer)
const options = toJson.mock.calls[0][1] as {
range: { s: { c: number }; e: { c: number } }
}
expect(options.range.e.c - options.range.s.c + 1).toBe(256)
expect(result.metadata?.truncated).toBe(true)
expect(result.content).toContain('16,384 total columns')
})
it('hard-caps rendered content and bounds sampled metadata independently', async () => {
const repeatedCell = 'x'.repeat(500)
const rows = Array.from({ length: 100 }, () => Array.from({ length: 256 }, () => repeatedCell))
const sheet = XLSX.utils.aoa_to_sheet(rows)
const book = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(book, sheet, 'Dense')
const buffer = XLSX.write(book, { type: 'buffer', bookType: 'xlsx' }) as Buffer
const result = await new XlsxParser().parseBuffer(buffer)
const sampledData = result.metadata?.sampledData as string[][]
const sampledCharacters = sampledData.flat().reduce((sum, value) => sum + value.length, 0)
expect(result.metadata?.contentSize).toBeLessThanOrEqual(10 * 1024 * 1024)
expect(result.content.length).toBeLessThan(10 * 1024 * 1024 + 200)
expect(sampledData.every((row) => row.length <= 32)).toBe(true)
expect(sampledData.flat().every((value) => value.length <= 256)).toBe(true)
expect(sampledCharacters).toBe(100 * 32 * 256)
})
it('still reports the workbook the sheet declares', async () => {
const result = await new XlsxParser().parseBuffer(inflatedRangeWorkbook())
@@ -98,4 +140,46 @@ describe('XlsxParser preview bound', () => {
expect(result.metadata?.truncated).toBe(false)
expect(result.content).not.toContain('total rows, showing first')
})
it('preserves a long cell in full when the aggregate output remains within budget', async () => {
const longCell = 'x'.repeat(2_000)
const sheet = XLSX.utils.aoa_to_sheet([['header'], [longCell]])
const book = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(book, sheet, 'Long cell')
const buffer = XLSX.write(book, { type: 'buffer', bookType: 'xlsx' }) as Buffer
const result = await new XlsxParser().parseBuffer(buffer)
expect(result.metadata?.truncated).toBe(false)
expect(result.content).toContain(longCell)
})
it.each(['', ' '])(
'does not treat an empty-string cell as extractable content',
async (cell) => {
const sheet = XLSX.utils.aoa_to_sheet([[cell]])
const book = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(book, sheet, 'Empty')
const buffer = XLSX.write(book, { type: 'buffer', bookType: 'xlsx' }) as Buffer
const result = await new XlsxParser().parseBuffer(buffer)
expect(result.metadata?.degraded).toBe(true)
}
)
it('measures the rendered content cap in UTF-8 bytes', async () => {
const rows = Array.from({ length: 50 }, () =>
Array.from({ length: 256 }, () => '🚀'.repeat(300))
)
const sheet = XLSX.utils.aoa_to_sheet(rows)
const book = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(book, sheet, 'Unicode')
const buffer = XLSX.write(book, { type: 'buffer', bookType: 'xlsx' }) as Buffer
const result = await new XlsxParser().parseBuffer(buffer)
expect(Buffer.byteLength(result.content, 'utf8')).toBeLessThanOrEqual(10 * 1024 * 1024)
expect(result.content).toContain('Content truncated due to size limits')
})
})
@@ -26,6 +26,10 @@ function buildAliasBomb(levels: number, width: number): string {
}
describe('parseYAMLBuffer', () => {
it('reports empty input with the typed parser taxonomy', async () => {
await expect(parseYAMLBuffer(Buffer.alloc(0))).rejects.toMatchObject({ code: 'empty_input' })
})
it('parses a normal YAML document', async () => {
const result = await parseYAMLBuffer(
Buffer.from('name: sim\nlist:\n - a\n - b\nnested:\n key: value\n')
+23 -6
View File
@@ -1,5 +1,6 @@
import { getErrorMessage } from '@sim/utils/errors'
import * as yaml from 'js-yaml'
import { FileParserError } from '@/lib/file-parsers/errors'
import type { FileParseResult } from '@/lib/file-parsers/types'
/**
@@ -34,9 +35,9 @@ const MAX_YAML_DEPTH = 500
* Distinct from a syntax error so callers can tell a malformed file apart from
* a resource-exhaustion (alias-expansion DoS) attempt.
*/
export class YamlComplexityError extends Error {
export class YamlComplexityError extends FileParserError {
constructor(message: string) {
super(message)
super('complexity_limit', message)
this.name = 'YamlComplexityError'
}
}
@@ -179,6 +180,10 @@ export function assertYamlWithinLimits(root: unknown): number {
* that its expanded form stays within safe complexity limits.
*/
function buildYamlResult(yamlData: unknown): FileParseResult {
if (yamlData === undefined) {
throw new FileParserError('empty_input', 'Empty YAML input provided')
}
const depth = assertYamlWithinLimits(yamlData)
const jsonContent = JSON.stringify(yamlData, null, 2)
@@ -207,8 +212,12 @@ export async function parseYAML(filePath: string): Promise<FileParseResult> {
const yamlData = yaml.load(content)
return buildYamlResult(yamlData)
} catch (error) {
if (error instanceof YamlComplexityError) throw error
throw new Error(`Invalid YAML: ${getErrorMessage(error, 'Unknown error')}`)
if (error instanceof FileParserError) throw error
throw new FileParserError(
'invalid_format',
`Invalid YAML: ${getErrorMessage(error, 'Unknown error')}`,
error
)
}
}
@@ -216,13 +225,21 @@ export async function parseYAML(filePath: string): Promise<FileParseResult> {
* Parse YAML from buffer
*/
export async function parseYAMLBuffer(buffer: Buffer): Promise<FileParseResult> {
if (!buffer || buffer.length === 0) {
throw new FileParserError('empty_input', 'Empty buffer provided')
}
const content = buffer.toString('utf-8')
try {
const yamlData = yaml.load(content)
return buildYamlResult(yamlData)
} catch (error) {
if (error instanceof YamlComplexityError) throw error
throw new Error(`Invalid YAML: ${getErrorMessage(error, 'Unknown error')}`)
if (error instanceof FileParserError) throw error
throw new FileParserError(
'invalid_format',
`Invalid YAML: ${getErrorMessage(error, 'Unknown error')}`,
error
)
}
}
+39 -5
View File
@@ -3,7 +3,7 @@
*/
import JSZip from 'jszip'
import { describe, expect, it } from 'vitest'
import { ZipBombError } from '@/lib/file-parsers/ooxml-limits'
import { ArchiveIntegrityError, ZipBombError } from '@/lib/file-parsers/ooxml-limits'
import { assertOoxmlArchiveWithinLimits, type OoxmlSizeLimits } from '@/lib/file-parsers/zip-guard'
const HIGH_LIMITS: OoxmlSizeLimits = {
@@ -31,6 +31,24 @@ async function buildZip(
const CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x02014b50
const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50
function buildCentralDirectoryOnly(entryCount: number, extraFieldBytesPerEntry = 0): Buffer {
const recordSize = 46 + extraFieldBytesPerEntry
const centralDirectory = Buffer.alloc(recordSize * entryCount)
for (let index = 0; index < entryCount; index++) {
const offset = index * recordSize
centralDirectory.writeUInt32LE(CENTRAL_DIRECTORY_HEADER_SIGNATURE, offset)
centralDirectory.writeUInt16LE(extraFieldBytesPerEntry, offset + 30)
}
const eocd = Buffer.alloc(22)
eocd.writeUInt32LE(0x06054b50, 0)
eocd.writeUInt16LE(entryCount, 8)
eocd.writeUInt16LE(entryCount, 10)
eocd.writeUInt32LE(centralDirectory.length, 12)
eocd.writeUInt32LE(0, 16)
return Buffer.concat([centralDirectory, eocd])
}
/**
* Rewrite every declared uncompressed size — in both the central directory and
* the local file headers — so the archive under-reports how much it expands to.
@@ -91,6 +109,22 @@ describe('assertOoxmlArchiveWithinLimits', () => {
expect(() => assertOoxmlArchiveWithinLimits(buffer, HIGH_LIMITS)).not.toThrow()
})
it('rejects a small archive with an excessive central-directory object count', () => {
const buffer = buildCentralDirectoryOnly(10_001)
expect(() => assertOoxmlArchiveWithinLimits(buffer)).toThrow(
/10001 entries, exceeding the maximum allowed 10000/
)
})
it('rejects excessive central-directory extra-field metadata', () => {
const buffer = buildCentralDirectoryOnly(65, 65_535)
expect(() => assertOoxmlArchiveWithinLimits(buffer)).toThrow(
/central-directory metadata .* exceeds the maximum allowed 4194304 bytes/
)
})
it('rejects an archive whose declared expanded size exceeds the absolute cap', async () => {
const buffer = await buildZip({ 'xl/worksheets/sheet1.xml': 'A'.repeat(200_000) })
expect(() =>
@@ -202,7 +236,7 @@ describe('assertOoxmlArchiveWithinLimits', () => {
it('fails closed for a ZIP-shaped buffer whose central directory is unparseable', () => {
const buffer = Buffer.alloc(64)
buffer.writeUInt32LE(0x04034b50, 0) // local file header signature, no valid EOCD
expect(() => assertOoxmlArchiveWithinLimits(buffer)).toThrow(ZipBombError)
expect(() => assertOoxmlArchiveWithinLimits(buffer)).toThrow(ArchiveIntegrityError)
})
it('rejects a decoy EOCD signature that does not validate against the buffer tail', async () => {
@@ -213,7 +247,7 @@ describe('assertOoxmlArchiveWithinLimits', () => {
const decoy = Buffer.alloc(64)
decoy.writeUInt32LE(0x06054b50, 0)
const tampered = Buffer.concat([realZip, decoy])
expect(() => assertOoxmlArchiveWithinLimits(tampered)).toThrow(ZipBombError)
expect(() => assertOoxmlArchiveWithinLimits(tampered)).toThrow(ArchiveIntegrityError)
})
it('rejects an archive that under-declares its uncompressed size', async () => {
@@ -222,7 +256,7 @@ describe('assertOoxmlArchiveWithinLimits', () => {
const honest = await buildZip({ 'word/document.xml': 'A'.repeat(200_000) })
const lying = underDeclareSizes(honest, 1000)
expect(() => assertOoxmlArchiveWithinLimits(lying, HIGH_LIMITS)).toThrow(ZipBombError)
expect(() => assertOoxmlArchiveWithinLimits(lying, HIGH_LIMITS)).toThrow(ArchiveIntegrityError)
expect(() => assertOoxmlArchiveWithinLimits(lying, HIGH_LIMITS)).toThrow(
/inflates beyond the 1000 bytes it declares/
)
@@ -259,7 +293,7 @@ describe('assertOoxmlArchiveWithinLimits', () => {
const honest = await buildZip({ 'xl/worksheets/sheet1.xml': 'A'.repeat(200_000) })
const split = setCompressionMethod(honest, 0, 'central')
expect(() => assertOoxmlArchiveWithinLimits(split, HIGH_LIMITS)).toThrow(ZipBombError)
expect(() => assertOoxmlArchiveWithinLimits(split, HIGH_LIMITS)).toThrow(ArchiveIntegrityError)
expect(() => assertOoxmlArchiveWithinLimits(split, HIGH_LIMITS)).toThrow(
/compression method 0 centrally but 8 locally/
)
+37 -6
View File
@@ -1,6 +1,9 @@
import { inflateRawSync } from 'zlib'
import { createLogger } from '@sim/logger'
import {
ArchiveIntegrityError,
MAX_OOXML_CENTRAL_DIRECTORY_EXTRA_BYTES,
MAX_OOXML_CENTRAL_DIRECTORY_RECORDS,
MAX_OOXML_ENTRY_UNCOMPRESSED_BYTES,
MAX_OOXML_TOTAL_UNCOMPRESSED_BYTES,
ZipBombError,
@@ -224,6 +227,10 @@ interface DeclaredSizeStats {
total: number
/** Largest single entry's declared uncompressed size. */
largestEntry: number
/** Records a downstream ZIP parser would materialize. */
entryCount: number
/** Summed central-directory extra-field bytes retained by ZIP parsers. */
totalExtraFieldBytes: number
}
/**
@@ -267,6 +274,7 @@ function sumDeclaredUncompressedSize(
let total = 0
let largestEntry = 0
let counted = 0
let totalExtraFieldBytes = 0
let cursor = location.offset
while (
cursor + CENTRAL_DIRECTORY_HEADER_MIN_SIZE <= buffer.length &&
@@ -282,12 +290,13 @@ function sumDeclaredUncompressedSize(
fileNameLength,
extraFieldLength
).uncompressedSize
totalExtraFieldBytes += extraFieldLength
total += entryBytes
if (entryBytes > largestEntry) {
largestEntry = entryBytes
}
if (total > limits.maxTotalUncompressedBytes || entryBytes > limits.maxEntryUncompressedBytes) {
return { total, largestEntry }
return { total, largestEntry, entryCount: counted + 1, totalExtraFieldBytes }
}
counted += 1
@@ -300,7 +309,7 @@ function sumDeclaredUncompressedSize(
return null
}
return { total, largestEntry }
return { total, largestEntry, entryCount: counted, totalExtraFieldBytes }
}
/**
@@ -506,14 +515,14 @@ export function assertOoxmlArchiveWithinLimits(
logger.warn('Rejected ZIP-shaped archive: central directory could not be parsed', {
compressedBytes: buffer.length,
})
throw new ZipBombError(
throw new ArchiveIntegrityError(
'Unable to inspect ZIP central directory; refusing to parse an unverifiable ZIP-shaped archive'
)
}
return
}
const { total: totalUncompressed, largestEntry } = declared
const { total: totalUncompressed, largestEntry, entryCount, totalExtraFieldBytes } = declared
if (largestEntry > limits.maxEntryUncompressedBytes) {
logger.warn('Rejected OOXML archive: a single entry exceeds the per-entry limit', {
@@ -537,6 +546,28 @@ export function assertOoxmlArchiveWithinLimits(
)
}
if (entryCount > MAX_OOXML_CENTRAL_DIRECTORY_RECORDS) {
logger.warn('Rejected OOXML archive: central-directory record count exceeds limit', {
entryCount,
maxEntryCount: MAX_OOXML_CENTRAL_DIRECTORY_RECORDS,
compressedBytes: buffer.length,
})
throw new ZipBombError(
`Archive contains ${entryCount} entries, exceeding the maximum allowed ${MAX_OOXML_CENTRAL_DIRECTORY_RECORDS}`
)
}
if (totalExtraFieldBytes > MAX_OOXML_CENTRAL_DIRECTORY_EXTRA_BYTES) {
logger.warn('Rejected OOXML archive: central-directory metadata exceeds limit', {
totalExtraFieldBytes,
maxExtraFieldBytes: MAX_OOXML_CENTRAL_DIRECTORY_EXTRA_BYTES,
compressedBytes: buffer.length,
})
throw new ZipBombError(
`Archive central-directory metadata (${totalExtraFieldBytes} bytes) exceeds the maximum allowed ${MAX_OOXML_CENTRAL_DIRECTORY_EXTRA_BYTES} bytes`
)
}
const ratio = totalUncompressed / Math.max(buffer.length, 1)
if (totalUncompressed > limits.ratioCheckFloorBytes && ratio > limits.maxCompressionRatio) {
logger.warn('Rejected OOXML archive: compression ratio exceeds limit', {
@@ -556,7 +587,7 @@ export function assertOoxmlArchiveWithinLimits(
logger.warn('Rejected ZIP-shaped archive: central directory could not be re-read', {
compressedBytes: buffer.length,
})
throw new ZipBombError(
throw new ArchiveIntegrityError(
'Unable to inspect ZIP central directory; refusing to parse an unverifiable ZIP-shaped archive'
)
}
@@ -568,6 +599,6 @@ export function assertOoxmlArchiveWithinLimits(
declaredTotalUncompressed: totalUncompressed,
compressedBytes: buffer.length,
})
throw new ZipBombError(`Archive contents do not match declared sizes: ${mismatch}`)
throw new ArchiveIntegrityError(`Archive contents do not match declared sizes: ${mismatch}`)
}
}
@@ -865,7 +865,10 @@ export const updateKnowledgeDocument = defineAuthorizedKnowledgeUseCase({
async execute({ principal, input, context }) {
if (input.markFailedDueToTimeout || input.retryProcessing) {
const outcome = input.markFailedDueToTimeout
? await performMarkKnowledgeDocumentTimedOut({ document: context.document })
? await performMarkKnowledgeDocumentTimedOut({
knowledgeBaseId: context.knowledgeBaseId,
document: context.document,
})
: await performRetryKnowledgeDocumentProcessing({
knowledgeBaseId: context.knowledgeBaseId,
document: context.document,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+7 -5
View File
@@ -179,10 +179,12 @@ export function getPlaceholderForFieldType(fieldType: string): string {
}
/**
* How long a document may sit in `processing` before its run is treated as dead.
* Minimum time the client waits before asking the server to classify an active
* document-processing run as dead.
*
* Lives here rather than beside the server-side claim helpers so the client can
* read the same number without importing a module that pulls in the database
* client. `processing-claim.ts` re-exports it for its own callers.
* Lives here so the client does not import server configuration. The server
* derives its authoritative threshold from the configured task
* duration and retry budget and may require longer. Keeping the client at the
* same 45-minute floor prevents the default UI from racing a legitimate run.
*/
export const KNOWLEDGE_DOCUMENT_PROCESSING_STALE_THRESHOLD_MS = 10 * 60 * 1000
export const KNOWLEDGE_DOCUMENT_PROCESSING_STALE_THRESHOLD_MS = 45 * 60 * 1000
@@ -42,6 +42,7 @@ vi.mock('@/lib/knowledge/documents/document-processor', () => ({
}))
vi.mock('@/lib/knowledge/embedding-models', () => ({
EMBEDDING_DIMENSIONS: 1536,
getEmbeddingModelInfo: vi.fn(() => ({ tokenizerProvider: 'openai' })),
}))
@@ -0,0 +1,222 @@
import { describe, expect, it } from 'vitest'
import { FileParserError, type FileParserErrorCode } from '@/lib/file-parsers/errors'
import { ArchiveIntegrityError, ZipBombError } from '@/lib/file-parsers/ooxml-limits'
import {
assertDocumentChunkCountWithinLimit,
classifyDocumentProcessingFailure,
MAX_DOCUMENT_CHUNKS,
PermanentDocumentProcessingError,
toPermanentDocumentProcessingError,
} from '@/lib/knowledge/documents/document-processing-error'
describe('document processing failure taxonomy', () => {
it('classifies archive safety rejections without exposing technical limits as the remedy', () => {
const failure = classifyDocumentProcessingFailure(
new ZipBombError('Archive entry xl/worksheets/sheet1.xml exceeds 67108864 bytes'),
'Vendor Spend.xlsx'
)
expect(failure).toEqual({
disposition: 'permanent',
code: 'archive_safety_limit',
userMessage:
'This file expands beyond the safe processing limit and was not indexed. Reduce its size or split it into smaller files, then retry.',
})
})
it('does not infer archive safety from untyped exception text', () => {
const failure = classifyDocumentProcessingFailure(
new Error(
'Failed to parse XLSX buffer: Archive total uncompressed size exceeds 157286400 bytes'
),
'Marketing plan.xlsx'
)
expect(failure).toMatchObject({
disposition: 'transient',
code: 'transient_processing_failure',
})
})
it('does not infer encryption from untyped exception text', () => {
const failure = classifyDocumentProcessingFailure(
new Error('Failed to parse XLSX buffer: File is password-protected'),
'Reconciliation.xlsx'
)
expect(failure).toMatchObject({
disposition: 'transient',
code: 'transient_processing_failure',
})
})
it('does not dead-letter Office files from untyped exception text', () => {
const failure = classifyDocumentProcessingFailure(
new Error('Failed to parse DOCX buffer: Failed to extract text from DOCX file'),
'Letterhead.dotx'
)
expect(failure).toMatchObject({
disposition: 'transient',
code: 'transient_processing_failure',
})
})
it('preserves typed no-text guidance', () => {
const error = new PermanentDocumentProcessingError(
'no_extractable_text',
'No text could be extracted. Re-save it as DOCX to index it.'
)
expect(classifyDocumentProcessingFailure(error, 'Contract.doc')).toEqual({
disposition: 'permanent',
code: 'no_extractable_text',
userMessage: error.message,
})
expect(toPermanentDocumentProcessingError(error, 'Contract.doc')).toBe(error)
})
it.each(['Contract.doc', 'Budget.xls', 'Deck.ppt'])(
'classifies an unreadable legacy Office file as repairable: %s',
(filename) => {
const failure = classifyDocumentProcessingFailure(
new FileParserError('invalid_format', 'The legacy Office file could not be parsed'),
filename
)
expect(failure).toMatchObject({
disposition: 'permanent',
code: 'unreadable_office_file',
userMessage: expect.stringContaining('re-save'),
})
}
)
it.each<{
parserCode: FileParserErrorCode
filename: string
disposition: 'permanent' | 'transient'
documentCode: string
}>([
{
parserCode: 'empty_input',
filename: 'empty.pdf',
disposition: 'permanent',
documentCode: 'invalid_file',
},
{
parserCode: 'unsupported_type',
filename: 'diagram.vsdx',
disposition: 'permanent',
documentCode: 'unsupported_file_type',
},
{
parserCode: 'encrypted_file',
filename: 'protected.xlsx',
disposition: 'permanent',
documentCode: 'encrypted_file',
},
{
parserCode: 'no_extractable_text',
filename: 'scan.pdf',
disposition: 'permanent',
documentCode: 'no_extractable_text',
},
{
parserCode: 'invalid_format',
filename: 'damaged.dotx',
disposition: 'permanent',
documentCode: 'unreadable_office_file',
},
{
parserCode: 'runtime_failure',
filename: 'valid.docx',
disposition: 'transient',
documentCode: 'transient_processing_failure',
},
{
parserCode: 'complexity_limit',
filename: 'large.yaml',
disposition: 'permanent',
documentCode: 'document_complexity_limit',
},
])(
'maps typed parser code $parserCode exhaustively into the document taxonomy',
({ parserCode, filename, disposition, documentCode }) => {
const failure = classifyDocumentProcessingFailure(
new FileParserError(parserCode, `parser failure: ${parserCode}`),
filename
)
expect(failure).toMatchObject({ disposition, code: documentCode })
}
)
it('keeps a successful-but-empty OCR result permanent and an OCR timeout transient', () => {
const empty = new PermanentDocumentProcessingError(
'no_extractable_text',
'No text could be extracted from this file.'
)
expect(classifyDocumentProcessingFailure(empty, 'scan.pdf')).toMatchObject({
disposition: 'permanent',
code: 'no_extractable_text',
})
expect(
classifyDocumentProcessingFailure(new Error('OCR API request timed out'), 'scan.pdf')
).toMatchObject({
disposition: 'transient',
code: 'transient_processing_failure',
})
})
it('keeps stale downloads and access failures transient', () => {
for (const error of [
Object.assign(new Error('Not Found'), { status: 404 }),
Object.assign(new Error('Access denied'), { status: 403 }),
]) {
expect(classifyDocumentProcessingFailure(error, 'Report.docx')).toMatchObject({
disposition: 'transient',
code: 'transient_processing_failure',
})
}
})
it('leaves infrastructure and provider failures transient', () => {
for (const error of [
new Error('Storage request timed out'),
new Error('Database connection terminated unexpectedly'),
new Error('Embedding provider returned 503'),
new TypeError('parseOfficeAsync is not a function'),
]) {
expect(classifyDocumentProcessingFailure(error, 'Report.docx')).toMatchObject({
disposition: 'transient',
code: 'transient_processing_failure',
})
expect(toPermanentDocumentProcessingError(error, 'Report.docx')).toBeNull()
}
})
it('rejects excessive chunk counts before embedding allocation without truncating content', () => {
expect(() => assertDocumentChunkCountWithinLimit(MAX_DOCUMENT_CHUNKS)).not.toThrow()
expect(() => assertDocumentChunkCountWithinLimit(MAX_DOCUMENT_CHUNKS + 1)).toThrowError(
expect.objectContaining({
code: 'document_complexity_limit',
message: expect.stringContaining('Split it into smaller files'),
})
)
})
it('distinguishes malformed archive integrity from an expanded-size limit', () => {
const failure = classifyDocumentProcessingFailure(
new ArchiveIntegrityError('Unable to inspect ZIP central directory'),
'damaged.docx'
)
expect(failure).toMatchObject({
disposition: 'permanent',
code: 'unreadable_office_file',
userMessage: expect.stringContaining('re-save'),
})
})
})
@@ -0,0 +1,244 @@
import { getErrorMessage } from '@sim/utils/errors'
import { isFileParserError } from '@/lib/file-parsers/errors'
import { ArchiveIntegrityError, ZipBombError } from '@/lib/file-parsers/ooxml-limits'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
export const DOCUMENT_PROCESSING_FAILURE_CODES = [
'archive_safety_limit',
'encrypted_file',
'no_extractable_text',
'unreadable_office_file',
'unsupported_file_type',
'invalid_file',
'document_complexity_limit',
'transient_processing_failure',
] as const
export type DocumentProcessingFailureCode = (typeof DOCUMENT_PROCESSING_FAILURE_CODES)[number]
export type DocumentProcessingFailure =
| {
readonly disposition: 'permanent'
readonly code: Exclude<DocumentProcessingFailureCode, 'transient_processing_failure'>
readonly userMessage: string
}
| {
readonly disposition: 'transient'
readonly code: 'transient_processing_failure'
readonly userMessage: string
}
/**
* A deterministic failure caused by the document bytes or format.
*
* The row remains `failed` and can still be retried explicitly after its
* content is replaced or repaired. The distinction is only about unattended
* retries: rerunning the same bytes cannot change this outcome.
*/
export class PermanentDocumentProcessingError extends Error {
readonly code: Exclude<DocumentProcessingFailureCode, 'transient_processing_failure'>
constructor(
code: Exclude<DocumentProcessingFailureCode, 'transient_processing_failure'>,
userMessage: string,
cause?: unknown
) {
super(userMessage, cause === undefined ? undefined : { cause })
this.name = 'PermanentDocumentProcessingError'
this.code = code
}
}
/**
* A mutable billing gate that must stop this attempt without consuming the
* document's unattended retry budget. A plan upgrade or credit top-up can make
* the same bytes processable, so this is intentionally not a permanent input
* failure and must not be retried immediately by Trigger.dev.
*/
export class UsageLimitDocumentProcessingError extends Error {
constructor(message: string) {
super(message)
this.name = 'UsageLimitDocumentProcessingError'
}
}
export function isUsageLimitDocumentProcessingError(
error: unknown
): error is UsageLimitDocumentProcessingError {
return error instanceof UsageLimitDocumentProcessingError
}
/**
* Maximum vectors and embedding records retained before the atomic index swap.
*
* Each knowledge-base vector is 1,536 JavaScript numbers. The old 100,000-chunk
* ceiling could retain well over a gigabyte before response JSON, array
* overhead, chunk text, provenance, and insert records. Five thousand bounds
* raw vector values to roughly 59 MiB while preserving the atomic replacement
* behavior instead of silently truncating indexed content.
*/
export const MAX_DOCUMENT_CHUNKS = 5_000
export function assertDocumentChunkCountWithinLimit(chunkCount: number): void {
if (chunkCount <= MAX_DOCUMENT_CHUNKS) return
throw new PermanentDocumentProcessingError(
'document_complexity_limit',
`This document produced ${chunkCount.toLocaleString()} index chunks, exceeding the safe limit of ${MAX_DOCUMENT_CHUNKS.toLocaleString()}. Split it into smaller files or increase its knowledge-base chunk size, then retry.`
)
}
export function isPermanentDocumentProcessingError(
error: unknown
): error is PermanentDocumentProcessingError {
return error instanceof PermanentDocumentProcessingError
}
const OFFICE_REPAIR_EXTENSIONS = new Set([
'doc',
'docx',
'docm',
'dotx',
'xls',
'xlsx',
'xlsm',
'xlsb',
'xltx',
'ppt',
'pptx',
'pptm',
'potx',
'odt',
'ods',
'odp',
])
function officeFormatName(filename: string): string {
const extension = getFileExtension(filename)
return extension ? extension.toUpperCase() : 'Office'
}
/**
* Classifies only failures whose retry behavior is known from stable parser
* evidence. Unknown exceptions stay transient so code, storage, database, and
* provider outages are never silently dead-lettered as bad user input.
*/
export function classifyDocumentProcessingFailure(
error: unknown,
filename: string
): DocumentProcessingFailure {
if (isPermanentDocumentProcessingError(error)) {
return {
disposition: 'permanent',
code: error.code,
userMessage: error.message,
}
}
const extension = getFileExtension(filename)
if (isFileParserError(error)) {
switch (error.code) {
case 'empty_input':
return {
disposition: 'permanent',
code: 'invalid_file',
userMessage: 'This file is empty or invalid. Replace it with a valid file and retry.',
}
case 'unsupported_type':
return {
disposition: 'permanent',
code: 'unsupported_file_type',
userMessage: 'This file type is not supported for indexing. Convert it and retry.',
}
case 'encrypted_file':
return {
disposition: 'permanent',
code: 'encrypted_file',
userMessage:
'This file is encrypted or password-protected. Remove the protection and retry.',
}
case 'no_extractable_text':
return {
disposition: 'permanent',
code: 'no_extractable_text',
userMessage: error.message,
}
case 'invalid_format':
return OFFICE_REPAIR_EXTENSIONS.has(extension)
? {
disposition: 'permanent',
code: 'unreadable_office_file',
userMessage: `This ${officeFormatName(filename)} file could not be read. Open and re-save it as a valid ${officeFormatName(filename)} file, then retry.`,
}
: {
disposition: 'permanent',
code: 'invalid_file',
userMessage:
'This file is invalid or unreadable. Replace it with a valid file and retry.',
}
case 'complexity_limit':
return {
disposition: 'permanent',
code: 'document_complexity_limit',
userMessage:
'This document exceeds safe processing complexity limits. Simplify it or split it into smaller files, then retry.',
}
case 'runtime_failure':
return {
disposition: 'transient',
code: 'transient_processing_failure',
userMessage: error.message,
}
default: {
const exhaustiveCode: never = error.code
return exhaustiveCode
}
}
}
if (error instanceof ArchiveIntegrityError) {
return OFFICE_REPAIR_EXTENSIONS.has(extension)
? {
disposition: 'permanent',
code: 'unreadable_office_file',
userMessage: `This ${officeFormatName(filename)} file could not be read. Open and re-save it as a valid ${officeFormatName(filename)} file, then retry.`,
}
: {
disposition: 'permanent',
code: 'invalid_file',
userMessage:
'This archive is invalid or unreadable. Replace it with a valid file and retry.',
}
}
if (error instanceof ZipBombError) {
return {
disposition: 'permanent',
code: 'archive_safety_limit',
userMessage:
'This file expands beyond the safe processing limit and was not indexed. Reduce its size or split it into smaller files, then retry.',
}
}
return {
disposition: 'transient',
code: 'transient_processing_failure',
userMessage: getErrorMessage(error, 'Document processing failed. Please retry.'),
}
}
/**
* Preserves a typed permanent error or converts stable parser evidence into
* one. Transient exceptions are returned unchanged by callers.
*/
export function toPermanentDocumentProcessingError(
error: unknown,
filename: string
): PermanentDocumentProcessingError | null {
if (isPermanentDocumentProcessingError(error)) return error
const failure = classifyDocumentProcessingFailure(error, filename)
return failure.disposition === 'permanent'
? new PermanentDocumentProcessingError(failure.code, failure.userMessage, error)
: null
}
@@ -3,25 +3,38 @@
*/
import {
dbChainMockFns,
defaultMockEnv,
hasMockCondition,
type MockCondition,
resetDbChainMock,
resetEnvFlagsMock,
schemaMock,
setEnvFlags,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockCheckActorUsageLimits,
mockBatchTrigger,
mockGenerateEmbeddings,
mockGetBoundWorkspaceFileSecretProvenanceByMetadata,
mockGetEmbeddingModelInfo,
mockGetFileMetadataByKeys,
mockProcessDocument,
mockTrigger,
} = vi.hoisted(() => ({
mockCheckActorUsageLimits: vi.fn(),
mockBatchTrigger: vi.fn(),
mockGenerateEmbeddings: vi.fn(),
mockGetBoundWorkspaceFileSecretProvenanceByMetadata: vi.fn(),
mockGetEmbeddingModelInfo: vi.fn(),
mockGetFileMetadataByKeys: vi.fn(),
mockProcessDocument: vi.fn(),
mockTrigger: vi.fn(),
}))
vi.mock('@trigger.dev/sdk', () => ({
tasks: { batchTrigger: mockBatchTrigger, trigger: mockTrigger },
}))
vi.mock('@/lib/billing/calculations/usage-monitor', () => ({
@@ -33,7 +46,8 @@ vi.mock('@/lib/knowledge/documents/document-processor', () => ({
}))
vi.mock('@/lib/knowledge/embedding-models', () => ({
getEmbeddingModelInfo: vi.fn(() => ({ tokenizerProvider: 'openai' })),
EMBEDDING_DIMENSIONS: 1536,
getEmbeddingModelInfo: mockGetEmbeddingModelInfo,
}))
vi.mock('@/lib/knowledge/embeddings', () => ({
@@ -54,7 +68,19 @@ vi.mock('@/lib/uploads/server/metadata', () => ({
getFileMetadataByKeys: mockGetFileMetadataByKeys,
}))
import { processDocumentAsync } from '@/lib/knowledge/documents/service'
import { env } from '@/lib/core/config/env'
import {
markInsideTriggerRun,
resetInsideTriggerRunForTests,
} from '@/lib/core/config/trigger-runtime'
import { EMBEDDING_QUOTA_EXHAUSTED_MESSAGE } from '@/lib/embeddings'
import { EmbeddingQuotaExhaustedError } from '@/lib/embeddings/client'
import {
PermanentDocumentProcessingError,
UsageLimitDocumentProcessingError,
} from '@/lib/knowledge/documents/document-processing-error'
import { processDocumentAsync, processDocumentsWithQueue } from '@/lib/knowledge/documents/service'
import { MAX_PROCESSING_ATTEMPTS } from '@/lib/knowledge/documents/types'
const PERSISTED_KEY = 'workspace/workspace-1/persisted.pdf'
const PERSISTED_URL = `/api/files/serve/${encodeURIComponent(PERSISTED_KEY)}?context=workspace`
@@ -158,6 +184,10 @@ describe('knowledge document processing source', () => {
chunks: [],
metadata: { chunkCount: 0, tokenCount: 0, characterCount: 0 },
})
mockGetEmbeddingModelInfo.mockReturnValue({
tokenizerProvider: 'openai',
maxInputTokens: 8191,
})
})
it('uses the persisted document source instead of stale queued source fields', async () => {
@@ -274,29 +304,25 @@ describe('processDocumentAsync write guards', () => {
chunks: [],
metadata: { chunkCount: 0, tokenCount: 0, characterCount: 0 },
})
mockGetEmbeddingModelInfo.mockReturnValue({
tokenizerProvider: 'openai',
maxInputTokens: 8191,
})
})
/** Asserts the write that set `status` exists, and returns its guard clause. */
function guardForStatusWrite(status: string): unknown {
expect(
dbChainMockFns.set.mock.calls.some(
(call) => (call[0] as Record<string, unknown> | undefined)?.processingStatus === status
)
).toBe(true)
// `set` and `where` are separate shared spies, so they cannot be correlated
// by index; the guard is identified by its own shape instead.
const guard = dbChainMockFns.where.mock.calls.find((call) =>
hasMockCondition(
call[0],
(node: MockCondition) =>
node.type === 'ne' &&
node.left === schemaMock.document.processingStatus &&
node.right === 'completed'
)
const setIndex = dbChainMockFns.set.mock.calls.findIndex(
(call) => (call[0] as Record<string, unknown> | undefined)?.processingStatus === status
)
expect(guard).toBeDefined()
return guard?.[0]
expect(setIndex).toBeGreaterThanOrEqual(0)
const setOrder = dbChainMockFns.set.mock.invocationCallOrder[setIndex]
const whereIndex = dbChainMockFns.where.mock.invocationCallOrder.findIndex(
(whereOrder) => whereOrder > setOrder
)
expect(whereIndex).toBeGreaterThanOrEqual(0)
return dbChainMockFns.where.mock.calls[whereIndex]?.[0]
}
it('never claims a document whose pass already completed', async () => {
@@ -323,6 +349,187 @@ describe('processDocumentAsync write guards', () => {
expect(guardForStatusWrite('processing')).toBeDefined()
})
it('signals ownership before a claimed processing attempt can fail', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([PERSISTED_CONTEXT])
.mockResolvedValueOnce([PERSISTED_PROVENANCE_ROW])
.mockResolvedValueOnce([{ id: 'document-1' }])
mockGetFileMetadataByKeys.mockResolvedValue([SOURCE_BINDING])
mockGetBoundWorkspaceFileSecretProvenanceByMetadata.mockResolvedValue(
new Map([[SOURCE_BINDING.id, { status: 'exact', entries: [] }]])
)
mockProcessDocument.mockRejectedValueOnce(new Error('processor failed after claim'))
const onClaimed = vi.fn()
await expect(
processDocumentAsync(
'knowledge-base-1',
'document-1',
{
filename: 'a.pdf',
fileUrl: 'https://example.com/a.pdf',
fileSize: 1,
mimeType: 'text/plain',
},
{},
undefined,
'request-1',
{ chargedAtDispatch: true, onClaimed }
)
).rejects.toThrow('processor failed after claim')
expect(onClaimed).toHaveBeenCalledTimes(1)
expect(guardForStatusWrite('processing')).toBeDefined()
expect(guardForStatusWrite('failed')).toBeDefined()
})
it('accepts a legacy queuedAt-only payload only while the row has no token', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([PERSISTED_CONTEXT])
.mockResolvedValueOnce([PERSISTED_PROVENANCE_ROW])
.mockResolvedValueOnce([{ id: 'document-1' }])
mockGetFileMetadataByKeys.mockResolvedValue([SOURCE_BINDING])
mockGetBoundWorkspaceFileSecretProvenanceByMetadata.mockResolvedValue(
new Map([[SOURCE_BINDING.id, { status: 'exact', entries: [] }]])
)
const processingQueuedAt = new Date('2026-08-24T22:00:00.000Z')
await processDocumentAsync(
'knowledge-base-1',
'document-1',
{
filename: 'a.pdf',
fileUrl: 'https://example.com/a.pdf',
fileSize: 1,
mimeType: 'text/plain',
},
{},
undefined,
'request-1',
{ chargedAtDispatch: true, processingQueuedAt }
)
const claimGuard = guardForStatusWrite('processing')
expect(
hasMockCondition(
claimGuard,
(node: MockCondition) =>
node.type === 'eq' &&
node.left === schemaMock.document.processingQueuedAt &&
node.right === processingQueuedAt
)
).toBe(true)
expect(
hasMockCondition(
claimGuard,
(node: MockCondition) =>
node.type === 'isNull' && node.column === schemaMock.document.processingQueueToken
)
).toBe(true)
})
it('accepts a pre-rollout payload only while the row has no token', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([PERSISTED_CONTEXT])
.mockResolvedValueOnce([PERSISTED_PROVENANCE_ROW])
.mockResolvedValueOnce([{ id: 'document-1' }])
mockGetFileMetadataByKeys.mockResolvedValue([SOURCE_BINDING])
mockGetBoundWorkspaceFileSecretProvenanceByMetadata.mockResolvedValue(
new Map([[SOURCE_BINDING.id, { status: 'exact', entries: [] }]])
)
await processDocumentAsync(
'knowledge-base-1',
'document-1',
{
filename: 'a.pdf',
fileUrl: 'https://example.com/a.pdf',
fileSize: 1,
mimeType: 'text/plain',
},
{},
undefined,
'request-1',
{ chargedAtDispatch: false }
)
const claimGuard = guardForStatusWrite('processing')
expect(
hasMockCondition(
claimGuard,
(node: MockCondition) =>
node.type === 'isNull' && node.column === schemaMock.document.processingQueueToken
)
).toBe(true)
expect(
hasMockCondition(
claimGuard,
(node: MockCondition) =>
node.type === 'eq' && node.left === schemaMock.document.processingQueuedAt
)
).toBe(false)
})
it('uses the queue token as the authoritative claim and final-write generation', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([PERSISTED_CONTEXT])
.mockResolvedValueOnce([PERSISTED_PROVENANCE_ROW])
.mockResolvedValueOnce([{ id: 'document-1' }])
mockGetFileMetadataByKeys.mockResolvedValue([SOURCE_BINDING])
mockGetBoundWorkspaceFileSecretProvenanceByMetadata.mockResolvedValue(
new Map([[SOURCE_BINDING.id, { status: 'exact', entries: [] }]])
)
await processDocumentAsync(
'knowledge-base-1',
'document-1',
{
filename: 'a.pdf',
fileUrl: 'https://example.com/a.pdf',
fileSize: 1,
mimeType: 'text/plain',
},
{},
undefined,
'request-1',
{
chargedAtDispatch: true,
processingQueueToken: 'request-1',
processingQueuedAt: new Date('2026-08-24T22:00:00.000Z'),
}
)
for (const status of ['processing', 'completed']) {
const guard = guardForStatusWrite(status)
expect(
hasMockCondition(
guard,
(node: MockCondition) =>
node.type === 'eq' &&
node.left === schemaMock.document.processingQueueToken &&
node.right === 'request-1'
)
).toBe(true)
expect(
hasMockCondition(
guard,
(node: MockCondition) =>
node.type === 'eq' &&
node.left === schemaMock.document.userExcluded &&
node.right === false
)
).toBe(true)
}
const completion = dbChainMockFns.set.mock.calls.find(
(call) => (call[0] as Record<string, unknown> | undefined)?.processingStatus === 'completed'
)
expect(completion?.[0]).toMatchObject({
processingQueueToken: null,
processingQueuedAt: null,
})
})
it('does not process or bill a document it failed to claim', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([PERSISTED_CONTEXT])
@@ -391,4 +598,432 @@ describe('processDocumentAsync write guards', () => {
expect(completion).toBeDefined()
expect((completion?.[0] as Record<string, unknown>).processingAttempts).toBe(0)
})
it('records a mutable usage-limit failure and refunds a charged dispatch attempt', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([PERSISTED_CONTEXT])
mockCheckActorUsageLimits.mockResolvedValue({
isExceeded: true,
message: 'Usage limit exceeded. Upgrade to continue.',
})
await expect(
processDocumentAsync(
'knowledge-base-1',
'document-1',
{
filename: 'a.pdf',
fileUrl: 'https://example.com/a.pdf',
fileSize: 1,
mimeType: 'text/plain',
},
{},
undefined,
undefined,
{
chargedAtDispatch: true,
processingQueuedAt: new Date('2026-08-24T22:00:00.000Z'),
}
)
).rejects.toBeInstanceOf(UsageLimitDocumentProcessingError)
expect(mockProcessDocument).not.toHaveBeenCalled()
const failure = dbChainMockFns.set.mock.calls.find(
(call) => (call[0] as Record<string, unknown> | undefined)?.processingStatus === 'failed'
)
expect(failure?.[0]).toMatchObject({
processingError: 'Usage limit exceeded. Upgrade to continue.',
})
const attempts = (failure?.[0] as Record<string, unknown>).processingAttempts as {
toSQL: () => { params: unknown[]; sql: string }
}
expect(attempts.toSQL().sql).toBe('GREATEST(? - 1, 0)')
expect(attempts.toSQL().params).toEqual([schemaMock.document.processingAttempts])
})
it('fails before embedding when a stored chunk exceeds the model input ceiling', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([PERSISTED_CONTEXT])
.mockResolvedValueOnce([PERSISTED_PROVENANCE_ROW])
.mockResolvedValueOnce([{ id: 'document-1' }])
mockGetFileMetadataByKeys.mockResolvedValue([SOURCE_BINDING])
mockGetBoundWorkspaceFileSecretProvenanceByMetadata.mockResolvedValue(
new Map([[SOURCE_BINDING.id, { status: 'exact', entries: [] }]])
)
mockGetEmbeddingModelInfo.mockReturnValue({
tokenizerProvider: 'openai',
maxInputTokens: 1,
})
mockProcessDocument.mockResolvedValue({
chunks: [
{
text: 'This chunk is too large for the selected embedding model.',
metadata: { startIndex: 0, endIndex: 57 },
},
],
metadata: { chunkCount: 1, tokenCount: 12, characterCount: 57 },
})
await expect(
processDocumentAsync('knowledge-base-1', 'document-1', {
filename: 'large.txt',
fileUrl: 'https://example.com/large.txt',
fileSize: 57,
mimeType: 'text/plain',
})
).rejects.toMatchObject({
name: 'PermanentDocumentProcessingError',
code: 'document_complexity_limit',
})
expect(mockGenerateEmbeddings).not.toHaveBeenCalled()
})
it('dead-letters deterministic input failures after recording an actionable reason', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([PERSISTED_CONTEXT])
.mockResolvedValueOnce([PERSISTED_PROVENANCE_ROW])
.mockResolvedValueOnce([{ id: 'document-1' }])
mockGetFileMetadataByKeys.mockResolvedValue([SOURCE_BINDING])
mockGetBoundWorkspaceFileSecretProvenanceByMetadata.mockResolvedValue(
new Map([[SOURCE_BINDING.id, { status: 'exact', entries: [] }]])
)
mockProcessDocument.mockRejectedValue(
new PermanentDocumentProcessingError(
'encrypted_file',
'This file is encrypted or password-protected. Remove the protection and retry.'
)
)
await expect(
processDocumentAsync('knowledge-base-1', 'document-1', {
filename: 'protected.xlsx',
fileUrl: 'https://example.com/protected.xlsx',
fileSize: 1,
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})
).rejects.toMatchObject({
code: 'encrypted_file',
})
const failure = dbChainMockFns.set.mock.calls.find(
(call) => (call[0] as Record<string, unknown> | undefined)?.processingStatus === 'failed'
)
expect(failure?.[0]).toMatchObject({
processingError:
'This file is encrypted or password-protected. Remove the protection and retry.',
processingAttempts: 5,
})
})
it('keeps infrastructure failures eligible for automatic recovery', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([PERSISTED_CONTEXT])
.mockResolvedValueOnce([PERSISTED_PROVENANCE_ROW])
.mockResolvedValueOnce([{ id: 'document-1' }])
mockGetFileMetadataByKeys.mockResolvedValue([SOURCE_BINDING])
mockGetBoundWorkspaceFileSecretProvenanceByMetadata.mockResolvedValue(
new Map([[SOURCE_BINDING.id, { status: 'exact', entries: [] }]])
)
mockProcessDocument.mockRejectedValue(new Error('Storage request timed out'))
await expect(
processDocumentAsync('knowledge-base-1', 'document-1', {
filename: 'report.docx',
fileUrl: 'https://example.com/report.docx',
fileSize: 1,
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
})
).rejects.toThrow('Storage request timed out')
const failure = dbChainMockFns.set.mock.calls.find(
(call) => (call[0] as Record<string, unknown> | undefined)?.processingStatus === 'failed'
)
expect(failure).toBeDefined()
expect(failure![0]).not.toHaveProperty('processingAttempts')
})
it.each([
{ chargedAtDispatch: true, refundsAttempt: true },
{ chargedAtDispatch: false, refundsAttempt: false },
])(
'refunds only a charged document attempt when provider credit is exhausted',
async ({ chargedAtDispatch, refundsAttempt }) => {
dbChainMockFns.limit
.mockResolvedValueOnce([PERSISTED_CONTEXT])
.mockResolvedValueOnce([PERSISTED_PROVENANCE_ROW])
.mockResolvedValueOnce([{ id: 'document-1' }])
mockGetFileMetadataByKeys.mockResolvedValue([SOURCE_BINDING])
mockGetBoundWorkspaceFileSecretProvenanceByMetadata.mockResolvedValue(
new Map([[SOURCE_BINDING.id, { status: 'exact', entries: [] }]])
)
mockProcessDocument.mockResolvedValue({
chunks: [{ text: 'Index me', metadata: { startIndex: 0, endIndex: 8 } }],
metadata: { chunkCount: 1, tokenCount: 2, characterCount: 8 },
})
mockGenerateEmbeddings.mockRejectedValue(new EmbeddingQuotaExhaustedError('openai'))
await expect(
processDocumentAsync(
'knowledge-base-1',
'document-1',
{
filename: 'report.docx',
fileUrl: 'https://example.com/report.docx',
fileSize: 1,
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
},
{},
undefined,
undefined,
{
chargedAtDispatch,
processingQueuedAt: new Date('2026-08-24T22:00:00.000Z'),
}
)
).rejects.toBeInstanceOf(EmbeddingQuotaExhaustedError)
const failure = dbChainMockFns.set.mock.calls.find(
(call) => (call[0] as Record<string, unknown> | undefined)?.processingStatus === 'failed'
)
expect(failure).toBeDefined()
expect(failure?.[0]).toMatchObject({
processingError: EMBEDDING_QUOTA_EXHAUSTED_MESSAGE,
})
const attempts = (failure![0] as Record<string, unknown>).processingAttempts as
| { toSQL: () => { params: unknown[]; sql: string } }
| undefined
if (refundsAttempt) {
expect(attempts?.toSQL().sql).toBe('GREATEST(? - 1, 0)')
expect(attempts?.toSQL().params).toEqual([schemaMock.document.processingAttempts])
} else {
expect(failure![0]).not.toHaveProperty('processingAttempts')
}
}
)
})
describe('in-process quota continuation dispatch', () => {
const envSnapshot = { ...env }
const queuedDocument = {
documentId: 'document-1',
filename: PERSISTED_CONTEXT.filename,
fileUrl: PERSISTED_CONTEXT.fileUrl,
fileSize: PERSISTED_CONTEXT.fileSize,
mimeType: PERSISTED_CONTEXT.mimeType,
}
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
resetInsideTriggerRunForTests()
setEnvFlags({ isTriggerDevEnabled: false })
for (const key of Object.keys(env)) delete (env as Record<string, unknown>)[key]
Object.assign(env, { ...defaultMockEnv, TRIGGER_SECRET_KEY: undefined })
dbChainMockFns.returning.mockResolvedValue([{ id: 'document-1' }])
dbChainMockFns.limit
.mockResolvedValueOnce([{ userId: 'knowledge-owner', workspaceId: null }])
.mockResolvedValueOnce([PERSISTED_CONTEXT])
.mockResolvedValueOnce([PERSISTED_PROVENANCE_ROW])
.mockResolvedValueOnce([{ id: 'document-1' }])
mockCheckActorUsageLimits.mockResolvedValue({ isExceeded: false })
mockGetFileMetadataByKeys.mockResolvedValue([SOURCE_BINDING])
mockGetBoundWorkspaceFileSecretProvenanceByMetadata.mockResolvedValue(
new Map([[SOURCE_BINDING.id, { status: 'exact', entries: [] }]])
)
mockProcessDocument.mockResolvedValue({
chunks: [{ text: 'Index me', metadata: { startIndex: 0, endIndex: 8 } }],
metadata: { chunkCount: 1, tokenCount: 2, characterCount: 8 },
})
mockGetEmbeddingModelInfo.mockReturnValue({
tokenizerProvider: 'openai',
maxInputTokens: 8191,
})
mockGenerateEmbeddings.mockRejectedValue(new EmbeddingQuotaExhaustedError('openai'))
mockTrigger.mockResolvedValue({ id: 'quota-continuation-run' })
})
afterEach(() => {
vi.restoreAllMocks()
resetInsideTriggerRunForTests()
resetEnvFlagsMock()
})
afterAll(() => {
for (const key of Object.keys(env)) delete (env as Record<string, unknown>)[key]
Object.assign(env, envSnapshot)
})
it('durably defers quota exhaustion in direct mode before reporting acceptance', async () => {
vi.spyOn(Date, 'now').mockReturnValue(1_000)
await expect(
processDocumentsWithQueue([queuedDocument], 'knowledge-base-1', {}, 'request-1', undefined)
).resolves.toEqual({ requested: 1, accepted: 1, failed: 0, failedDocumentIds: [] })
expect(mockTrigger).toHaveBeenCalledWith(
'knowledge-process-document',
expect.objectContaining({
documentId: 'document-1',
processingQueuedAt: expect.any(String),
quotaRetryCount: 1,
}),
expect.objectContaining({
idempotencyKey: 'knowledge-quota-document-1-request-1-1',
delay: expect.any(Date),
})
)
const deferredUntil = mockTrigger.mock.calls[0]?.[2]?.delay as Date
expect(deferredUntil.getTime()).toBeGreaterThanOrEqual(1_000 + 5 * 60 * 1000 * 0.8)
expect(deferredUntil.getTime()).toBeLessThanOrEqual(1_000 + 5 * 60 * 1000 * 1.2)
const deferredWriteIndex = dbChainMockFns.set.mock.calls.findIndex(
(call) =>
(call[0] as Record<string, unknown> | undefined)?.processingDeferredUntil instanceof Date
)
expect(deferredWriteIndex).toBeGreaterThanOrEqual(0)
expect(dbChainMockFns.set.mock.calls[deferredWriteIndex]?.[0]).toMatchObject({
processingStatus: 'pending',
processingQueuedAt: deferredUntil,
processingStartedAt: null,
processingDeferredUntil: deferredUntil,
processingCompletedAt: null,
processingError: null,
})
expect(mockTrigger.mock.invocationCallOrder[0]).toBeLessThan(
dbChainMockFns.set.mock.invocationCallOrder[deferredWriteIndex]
)
})
it('preserves a tokenless queue stamp across an accepted quota continuation', async () => {
const originalQueuedAt = new Date('2026-08-24T22:00:00.000Z')
const deferredUntil = new Date('2026-08-24T23:00:00.000Z')
resetDbChainMock()
dbChainMockFns.returning.mockResolvedValue([{ id: 'document-1' }])
dbChainMockFns.limit
.mockResolvedValueOnce([PERSISTED_CONTEXT])
.mockResolvedValueOnce([PERSISTED_PROVENANCE_ROW])
.mockResolvedValueOnce([{ id: 'document-1' }])
mockGetFileMetadataByKeys.mockResolvedValue([SOURCE_BINDING])
mockGetBoundWorkspaceFileSecretProvenanceByMetadata.mockResolvedValue(
new Map([[SOURCE_BINDING.id, { status: 'exact', entries: [] }]])
)
mockProcessDocument.mockResolvedValue({
chunks: [{ text: 'Index me', metadata: { startIndex: 0, endIndex: 8 } }],
metadata: { chunkCount: 1, tokenCount: 2, characterCount: 8 },
})
mockGenerateEmbeddings.mockRejectedValue(new EmbeddingQuotaExhaustedError('openai'))
await expect(
processDocumentAsync(
'knowledge-base-1',
'document-1',
{
filename: 'report.docx',
fileUrl: 'https://example.com/report.docx',
fileSize: 1,
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
},
{},
undefined,
'request-1',
{
chargedAtDispatch: false,
processingQueuedAt: originalQueuedAt,
scheduleQuotaContinuation: vi.fn().mockResolvedValue(deferredUntil),
}
)
).rejects.toBeInstanceOf(EmbeddingQuotaExhaustedError)
const deferredWrite = dbChainMockFns.set.mock.calls.find(
(call) =>
(call[0] as Record<string, unknown> | undefined)?.processingDeferredUntil === deferredUntil
)
expect(deferredWrite?.[0]).toMatchObject({
processingStatus: 'pending',
processingDeferredUntil: deferredUntil,
})
expect(deferredWrite?.[0]).not.toHaveProperty('processingQueuedAt')
expect(
dbChainMockFns.where.mock.calls.some((call) =>
hasMockCondition(
call[0],
(node) =>
node.type === 'eq' &&
node.left === schemaMock.document.processingQueuedAt &&
node.right === originalQueuedAt
)
)
).toBe(true)
})
it('stops automatic retries after the bounded quota continuation chain is exhausted', async () => {
resetDbChainMock()
dbChainMockFns.returning.mockResolvedValue([{ id: 'document-1' }])
dbChainMockFns.limit
.mockResolvedValueOnce([PERSISTED_CONTEXT])
.mockResolvedValueOnce([PERSISTED_PROVENANCE_ROW])
.mockResolvedValueOnce([{ id: 'document-1' }])
await expect(
processDocumentAsync(
'knowledge-base-1',
'document-1',
queuedDocument,
{},
undefined,
'request-1',
{
chargedAtDispatch: false,
processingQueuedAt: new Date('2026-08-24T22:00:00.000Z'),
quotaContinuationExhausted: true,
}
)
).rejects.toBeInstanceOf(EmbeddingQuotaExhaustedError)
const failure = dbChainMockFns.set.mock.calls.find(
(call) => (call[0] as Record<string, unknown> | undefined)?.processingStatus === 'failed'
)
expect(failure?.[0]).toMatchObject({
processingError: EMBEDDING_QUOTA_EXHAUSTED_MESSAGE,
processingAttempts: MAX_PROCESSING_ATTEMPTS,
})
expect(mockTrigger).not.toHaveBeenCalled()
})
it('durably defers quota exhaustion after a failed Trigger batch fallback', async () => {
markInsideTriggerRun()
mockBatchTrigger.mockRejectedValue(new Error('batch unavailable'))
await expect(
processDocumentsWithQueue([queuedDocument], 'knowledge-base-1', {}, 'request-1', undefined)
).resolves.toEqual({ requested: 1, accepted: 1, failed: 0, failedDocumentIds: [] })
expect(mockBatchTrigger).toHaveBeenCalledTimes(1)
expect(mockTrigger).toHaveBeenCalledWith(
'knowledge-process-document',
expect.objectContaining({ documentId: 'document-1', quotaRetryCount: 1 }),
expect.objectContaining({
idempotencyKey: 'knowledge-quota-document-1-request-1-1',
})
)
})
it('keeps a claimed direct dispatch accepted when quota continuation handoff fails', async () => {
mockTrigger.mockRejectedValue(new Error('continuation unavailable'))
await expect(
processDocumentsWithQueue([queuedDocument], 'knowledge-base-1', {}, 'request-1', undefined)
).resolves.toEqual({ requested: 1, accepted: 1, failed: 0, failedDocumentIds: [] })
const failure = dbChainMockFns.set.mock.calls.find(
(call) =>
(call[0] as Record<string, unknown> | undefined)?.processingError ===
'continuation unavailable'
)
expect(failure?.[0]).toMatchObject({
processingStatus: 'failed',
processingDeferredUntil: null,
})
})
})
@@ -0,0 +1,98 @@
/**
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockParseBuffer } = vi.hoisted(() => ({
mockParseBuffer: vi.fn(),
}))
vi.mock('@/lib/file-parsers', () => ({
parseBuffer: mockParseBuffer,
}))
import { ChunkLimitExceededError } from '@/lib/chunkers/chunk-budget'
import { TokenChunker } from '@/lib/chunkers/token-chunker'
import {
MAX_DOCUMENT_CHUNKS,
type PermanentDocumentProcessingError,
} from '@/lib/knowledge/documents/document-processing-error'
import { processDocument } from '@/lib/knowledge/documents/document-processor'
describe('document chunk production ceiling', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('translates the shared chunk limit once into a permanent complexity failure', async () => {
mockParseBuffer.mockResolvedValue({
content: Array.from({ length: MAX_DOCUMENT_CHUNKS + 1 }, () => 'word').join(' '),
metadata: {},
})
const processing = processDocument(
'data:text/plain;base64,dGVzdA==',
'large.txt',
'text/plain',
1,
0,
1,
undefined,
undefined,
'token'
)
await expect(processing).rejects.toMatchObject({
name: 'PermanentDocumentProcessingError',
code: 'document_complexity_limit',
cause: expect.any(ChunkLimitExceededError),
} satisfies Partial<PermanentDocumentProcessingError>)
})
it('never completes nonempty content with zero chunks', async () => {
mockParseBuffer.mockResolvedValue({ content: 'x', metadata: {} })
vi.spyOn(TokenChunker.prototype, 'chunk').mockResolvedValue([])
await expect(
processDocument(
'data:text/plain;base64,eA==',
'filtered.txt',
'text/plain',
100,
0,
10,
undefined,
undefined,
'token'
)
).rejects.toMatchObject({
name: 'PermanentDocumentProcessingError',
code: 'no_extractable_text',
} satisfies Partial<PermanentDocumentProcessingError>)
})
it('preserves bounded partial indexing for a parser-limited document', async () => {
const content = 'name,value\nfirst,1\nsecond,2\n[Content truncated: showing first 1,000 rows]'
mockParseBuffer.mockResolvedValue({
content,
metadata: { truncated: true, headers: ['name', 'value'], rowCount: 2_000 },
})
const result = await processDocument(
'data:text/csv;base64,dGVzdA==',
'large.csv',
'text/csv',
100,
0,
10
)
expect(result.chunks.length).toBeGreaterThan(0)
expect(result.metadata.characterCount).toBe(content.length)
})
})

Some files were not shown because too many files have changed in this diff Show More